Quota foundation: overrides, resolution, race-safe consumption (#22)
All checks were successful
CD / Build and push images (push) Successful in 1m46s
CI / Lint, typecheck, test (push) Successful in 1m17s
CI / Auth e2e pack (push) Successful in 1m39s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m6s
CD / Promote to Int (push) Successful in 10s

- quota_overrides + pond_usage models (BigInt values, unique per
  subject+key); migration 20260705185146_quotas
- instance-default quota keys in the settings registry (editors 5,
  readers 50, additional ponds 0, storage 1 GiB, max file 25 MiB)
- QuotaService: getEffective with pond → user → instance resolution
  (zero counts as a value, not a gap); assertCanCreateSharedPond and
  checkAndConsume serialize via pg_advisory_xact_lock inside the guarded
  write's transaction; release never drops below zero
- pond creation enforces additional_ponds (personal ponds don't count);
  quota errors carry code quota_exceeded + {quotaKey, limit}, localized
- seed grants fixtures an additional_ponds override (default is 0)
- table-driven resolution tests, parallel-consumption test, e2e for the
  pond-creation limit

Closes #22

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpQz6ypHJsLfMf4S6fyQEB
This commit is contained in:
Claude Fable 5 2026-07-05 20:55:59 +02:00
parent f0850eecd3
commit 64928f0ac0
14 changed files with 431 additions and 9 deletions

View File

@ -0,0 +1,31 @@
-- CreateEnum
CREATE TYPE "QuotaSubjectType" AS ENUM ('USER', 'POND');
-- CreateTable
CREATE TABLE "quota_overrides" (
"id" TEXT NOT NULL,
"subject_type" "QuotaSubjectType" NOT NULL,
"subject_id" TEXT NOT NULL,
"quota_key" TEXT NOT NULL,
"value" BIGINT NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "quota_overrides_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "pond_usage" (
"pond_id" TEXT NOT NULL,
"storage_bytes_used" BIGINT NOT NULL DEFAULT 0,
"editor_count" INTEGER NOT NULL DEFAULT 0,
"reader_count" INTEGER NOT NULL DEFAULT 0,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "pond_usage_pkey" PRIMARY KEY ("pond_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "quota_overrides_subject_type_subject_id_quota_key_key" ON "quota_overrides"("subject_type", "subject_id", "quota_key");
-- AddForeignKey
ALTER TABLE "pond_usage" ADD CONSTRAINT "pond_usage_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -73,11 +73,47 @@ model Pond {
deletedBy String? @map("deleted_by")
owner User @relation(fields: [ownerId], references: [id])
usage PondUsage?
@@index([ownerId])
@@map("ponds")
}
enum QuotaSubjectType {
USER
POND
}
/// Per-user/per-pond quota values (ADR 0011). Resolution: pond override →
/// user override → instance default (QuotaService). `value` is BigInt so
/// storage limits beyond 2 GiB fit.
model QuotaOverride {
id String @id @default(uuid())
subjectType QuotaSubjectType @map("subject_type")
subjectId String @map("subject_id")
quotaKey String @map("quota_key")
value BigInt
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([subjectType, subjectId, quotaKey])
@@map("quota_overrides")
}
/// Cached usage counters per pond, updated transactionally with the
/// guarded writes (uploads land in M2 #27, membership in M5); reconciled
/// nightly by a maintenance job (operations.md).
model PondUsage {
pondId String @id @map("pond_id")
storageBytesUsed BigInt @default(0) @map("storage_bytes_used")
editorCount Int @default(0) @map("editor_count")
readerCount Int @default(0) @map("reader_count")
updatedAt DateTime @updatedAt @map("updated_at")
pond Pond @relation(fields: [pondId], references: [id], onDelete: Cascade)
@@map("pond_usage")
}
/// One row per login method. `provider` is "password" today and
/// "oidc:<issuer>" later; `credential` holds the Argon2id hash for
/// password identities.

View File

@ -91,6 +91,24 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise<void> {
},
});
}
// The instance default for additional_ponds is 0 (ADR 0011) — give
// the fixtures headroom so pond flows are exercisable in dev/e2e.
await prisma.quotaOverride.upsert({
where: {
subjectType_subjectId_quotaKey: {
subjectType: 'USER',
subjectId: user.id,
quotaKey: 'additional_ponds',
},
},
create: {
subjectType: 'USER',
subjectId: user.id,
quotaKey: 'additional_ponds',
value: 100,
},
update: { value: 100 },
});
}
}

View File

@ -48,6 +48,16 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
});
const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600);
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
// additional_ponds defaults to 0 (ADR 0011) — the shared-pond tests
// need headroom; the outsider stays at the default for the quota test.
await prisma.quotaOverride.create({
data: {
subjectType: 'USER',
subjectId: ownerUser.id,
quotaKey: 'additional_ponds',
value: 100,
},
});
ownerCookie = await loginOf(owner.username);
// The outsider doubles as Site Admin in the trash/restore tests.
@ -63,6 +73,13 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
});
afterAll(async () => {
const users = await prisma.user.findMany({
where: { username: { contains: suffix } },
select: { id: true },
});
await prisma.quotaOverride.deleteMany({
where: { subjectId: { in: users.map((u) => u.id) } },
});
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
@ -144,6 +161,39 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
.expect(404);
});
it('enforces the additional_ponds quota (default 0, override wins)', async () => {
const res = await api()
.post('/api/v1/ponds')
.set('Cookie', outsiderCookie)
.send({ name: `Quotateich ${suffix}` })
.expect(403);
expect(res.body.code).toBe('quota_exceeded');
expect(res.body.details).toMatchObject({ quotaKey: 'additional_ponds', limit: 0 });
const outsiderUser = await prisma.user.findUniqueOrThrow({
where: { username: outsider.username },
});
await prisma.quotaOverride.create({
data: {
subjectType: 'USER',
subjectId: outsiderUser.id,
quotaKey: 'additional_ponds',
value: 1,
},
});
await api()
.post('/api/v1/ponds')
.set('Cookie', outsiderCookie)
.send({ name: `Quotateich ${suffix}` })
.expect(201);
const second = await api()
.post('/api/v1/ponds')
.set('Cookie', outsiderCookie)
.send({ name: `Quotateich zwei ${suffix}` })
.expect(403);
expect(second.body.details.limit).toBe(1);
});
it('soft-deletes a shared pond; Site Admin sees trash and restores', async () => {
const created = await api()
.post('/api/v1/ponds')

View File

@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { QuotasModule } from '../quotas/quotas.module';
import { InterimAccessService } from './interim-access.service';
import { PondsController } from './ponds.controller';
import { PondsService } from './ponds.service';
@Module({
imports: [QuotasModule],
controllers: [PondsController],
providers: [PondsService, InterimAccessService],
exports: [PondsService, InterimAccessService],

View File

@ -10,6 +10,7 @@ import { Pond, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service';
import { InterimAccessService } from './interim-access.service';
@Injectable()
@ -17,6 +18,7 @@ export class PondsService {
constructor(
private readonly prisma: PrismaService,
private readonly access: InterimAccessService,
private readonly quotas: QuotaService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PondsService.name);
@ -60,15 +62,21 @@ export class PondsService {
}
async createShared(owner: User, input: CreatePondInput): Promise<PondView> {
const pond = await this.prisma.pond.create({
const slug = await this.generateUniqueSlug(input.name, owner.username);
// Quota check and create share one transaction — the advisory lock in
// the check makes concurrent creations by the same user race-safe.
const pond = await this.prisma.$transaction(async (tx) => {
await this.quotas.assertCanCreateSharedPond(tx, owner.id);
return tx.pond.create({
data: {
slug: await this.generateUniqueSlug(input.name, owner.username),
slug,
name: input.name,
description: input.description,
type: 'SHARED',
ownerId: owner.id,
},
});
});
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
return this.viewOf(pond);
}

View File

@ -0,0 +1,130 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { QuotaService } from './quota.service';
describe.skipIf(!hasTestDb)('QuotaService (db, issue #22)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let quotas: QuotaService;
const suffix = uniqueSuffix();
let userId: string;
let pondId: string;
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
quotas = app.get(QuotaService);
const user = await prisma.user.create({
data: {
username: `quinn-${suffix}`,
email: `quinn-${suffix}@example.org`,
displayName: 'Quinn Quota',
status: 'ACTIVE',
},
});
userId = user.id;
const pond = await prisma.pond.create({
data: { slug: `quota-${suffix}`, name: 'Quota Pond', type: 'PERSONAL', ownerId: userId },
});
pondId = pond.id;
});
afterAll(async () => {
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [userId, pondId] } } });
await prisma.pond.deleteMany({ where: { ownerId: userId } });
await prisma.user.delete({ where: { id: userId } });
await prisma.$disconnect();
await app.close();
});
async function setOverride(
subjectType: 'USER' | 'POND',
subjectId: string,
value: number,
): Promise<void> {
await prisma.quotaOverride.upsert({
where: {
subjectType_subjectId_quotaKey: { subjectType, subjectId, quotaKey: 'storage_bytes' },
},
create: { subjectType, subjectId, quotaKey: 'storage_bytes', value },
update: { value },
});
}
async function clearOverrides(): Promise<void> {
await prisma.quotaOverride.deleteMany({
where: { subjectId: { in: [userId, pondId] }, quotaKey: 'storage_bytes' },
});
}
// -------------------------------------------------- resolution ladder
const GIB = 1024 * 1024 * 1024;
const cases: Array<{
name: string;
userOverride: number | null;
pondOverride: number | null;
expected: number;
}> = [
{
name: 'no overrides → instance default',
userOverride: null,
pondOverride: null,
expected: GIB,
},
{ name: 'user override only', userOverride: 123, pondOverride: null, expected: 123 },
{ name: 'pond override only', userOverride: null, pondOverride: 456, expected: 456 },
{
name: 'pond override beats user override',
userOverride: 123,
pondOverride: 456,
expected: 456,
},
{
name: 'zero is a valid override, not a gap',
userOverride: 123,
pondOverride: 0,
expected: 0,
},
];
for (const c of cases) {
it(`resolves: ${c.name}`, async () => {
await clearOverrides();
if (c.userOverride !== null) await setOverride('USER', userId, c.userOverride);
if (c.pondOverride !== null) await setOverride('POND', pondId, c.pondOverride);
expect(await quotas.getEffective('storage_bytes', { userId, pondId })).toBe(c.expected);
});
}
// -------------------------------------------------- consumption
it('consumes storage transactionally and rejects what does not fit', async () => {
await clearOverrides();
await setOverride('POND', pondId, 1000);
await prisma.pondUsage.deleteMany({ where: { pondId } });
// Two parallel consumers want 600 each — exactly one may win.
const results = await Promise.allSettled([
quotas.checkAndConsume(pondId, userId, 600),
quotas.checkAndConsume(pondId, userId, 600),
]);
const fulfilled = results.filter((r) => r.status === 'fulfilled');
expect(fulfilled).toHaveLength(1);
const usage = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } });
expect(Number(usage.storageBytesUsed)).toBe(600);
});
it('releases storage without dropping below zero', async () => {
await quotas.release(pondId, 400);
let usage = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } });
expect(Number(usage.storageBytesUsed)).toBe(200);
await quotas.release(pondId, 9999);
usage = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } });
expect(Number(usage.storageBytesUsed)).toBe(0);
});
});

View File

@ -0,0 +1,105 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { QuotaKey } from '@dorfteich/shared';
import { Prisma, QuotaSubjectType } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingKey, InstanceSettingsService } from '../settings/instance-settings.service';
const SETTING_FOR_KEY: Record<QuotaKey, InstanceSettingKey> = {
editors_per_pond: 'quota.editorsPerPond',
readers_per_pond: 'quota.readersPerPond',
additional_ponds: 'quota.additionalPonds',
storage_bytes: 'quota.storageBytes',
max_file_bytes: 'quota.maxFileBytes',
};
/** 403 with the key and the limit so the client can name both (i18n). */
export function quotaExceeded(quotaKey: QuotaKey, limit: number): ForbiddenException {
return new ForbiddenException({ code: 'quota_exceeded', details: { quotaKey, limit } });
}
/**
* Quota resolution and race-safe consumption (issue #22, ADR 0011).
* Every quota question goes through `getEffective`; every guarded write
* consumes inside the same transaction as the write it protects.
*/
@Injectable()
export class QuotaService {
constructor(
private readonly prisma: PrismaService,
private readonly settings: InstanceSettingsService,
) {}
/** Pond override → user override → instance default; most specific wins. */
async getEffective(key: QuotaKey, scope: { userId?: string; pondId?: string }): Promise<number> {
if (scope.pondId) {
const pondValue = await this.findOverride('POND', scope.pondId, key);
if (pondValue !== null) return pondValue;
}
if (scope.userId) {
const userValue = await this.findOverride('USER', scope.userId, key);
if (userValue !== null) return userValue;
}
return (await this.settings.get(SETTING_FOR_KEY[key])) as number;
}
/**
* Guards creating another shared pond. Must run inside the transaction
* that creates the pond; the advisory lock serializes competing
* creations by the same user so the count cannot race.
*/
async assertCanCreateSharedPond(tx: Prisma.TransactionClient, userId: string): Promise<void> {
// ::text because Prisma cannot deserialize the function's void result.
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext(${`quota:additional_ponds:${userId}`}))::text`;
const limit = await this.getEffective('additional_ponds', { userId });
const owned = await tx.pond.count({
// The personal pond never counts against `additional_ponds`.
where: { ownerId: userId, type: 'SHARED', deletedAt: null },
});
if (owned >= limit) throw quotaExceeded('additional_ponds', limit);
}
/**
* Consumes storage budget for a pond, atomically against the effective
* limit. Throws `quota_exceeded` without changing the counter when the
* delta would not fit.
*/
async checkAndConsume(pondId: string, ownerUserId: string, deltaBytes: number): Promise<void> {
const limit = await this.getEffective('storage_bytes', { userId: ownerUserId, pondId });
await this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext(${`quota:storage:${pondId}`}))::text`;
const usage = await tx.pondUsage.upsert({
where: { pondId },
create: { pondId },
update: {},
});
if (Number(usage.storageBytesUsed) + deltaBytes > limit) {
throw quotaExceeded('storage_bytes', limit);
}
await tx.pondUsage.update({
where: { pondId },
data: { storageBytesUsed: { increment: deltaBytes } },
});
});
}
/** Returns storage budget (file deleted/purged); the counter never goes below zero. */
async release(pondId: string, deltaBytes: number): Promise<void> {
await this.prisma.$executeRaw`
UPDATE pond_usage
SET storage_bytes_used = GREATEST(storage_bytes_used - ${deltaBytes}, 0),
updated_at = now()
WHERE pond_id = ${pondId}`;
}
private async findOverride(
subjectType: QuotaSubjectType,
subjectId: string,
quotaKey: QuotaKey,
): Promise<number | null> {
const row = await this.prisma.quotaOverride.findUnique({
where: { subjectType_subjectId_quotaKey: { subjectType, subjectId, quotaKey } },
});
return row === null ? null : Number(row.value);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { QuotaService } from './quota.service';
@Module({
providers: [QuotaService],
exports: [QuotaService],
})
export class QuotasModule {}

View File

@ -14,6 +14,21 @@ export const INSTANCE_SETTINGS = {
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
// Instance-default quotas (ADR 0011); per-user/per-pond overrides live
// in quota_overrides and win over these (QuotaService, issue #22).
'quota.editorsPerPond': z.number().int().min(0).default(5),
'quota.readersPerPond': z.number().int().min(0).default(50),
'quota.additionalPonds': z.number().int().min(0).default(0),
'quota.storageBytes': z
.number()
.int()
.min(0)
.default(1024 * 1024 * 1024),
'quota.maxFileBytes': z
.number()
.int()
.min(0)
.default(25 * 1024 * 1024),
} as const;
export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS;

View File

@ -18,6 +18,7 @@
"csrf_origin_mismatch": "Die Anfrage kam von einer unerwarteten Herkunft.",
"cannot_revoke_current_session": "Beende deine aktuelle Sitzung über die Abmeldung.",
"personal_pond_undeletable": "Der persönliche Teich kann nicht gelöscht werden.",
"quota_exceeded": "Das Kontingent ist erreicht (Limit: {{limit}}).",
"network": "Der Server war nicht erreichbar.",
"validation": {
"required": "Dieses Feld ist erforderlich.",

View File

@ -18,6 +18,7 @@
"csrf_origin_mismatch": "The request came from an unexpected origin.",
"cannot_revoke_current_session": "Use sign-out to end your current session.",
"personal_pond_undeletable": "The personal pond cannot be deleted.",
"quota_exceeded": "The quota has been reached (limit: {{limit}}).",
"network": "The server could not be reached.",
"validation": {
"required": "This field is required.",

View File

@ -4,3 +4,4 @@ export * from './env';
export * from './health';
export * from './i18n-tools';
export * from './ponds';
export * from './quotas';

View File

@ -0,0 +1,14 @@
/**
* Quota dimensions (ADR 0011, issue #22). Each value resolves on the
* three-level ladder: pond override user override instance default
* the most specific wins, mirroring the permission philosophy.
*/
export const QUOTA_KEYS = [
'editors_per_pond',
'readers_per_pond',
'additional_ponds',
'storage_bytes',
'max_file_bytes',
] as const;
export type QuotaKey = (typeof QUOTA_KEYS)[number];