From 64928f0ac0dbd8349bfe005eccfcd3ea3312f05e Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 5 Jul 2026 20:55:59 +0200 Subject: [PATCH] Quota foundation: overrides, resolution, race-safe consumption (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01UpQz6ypHJsLfMf4S6fyQEB --- .../20260705185146_quotas/migration.sql | 31 +++++ apps/api/prisma/schema.prisma | 38 ++++- apps/api/prisma/seed.ts | 18 +++ apps/api/src/ponds/ponds.e2e.db.test.ts | 50 +++++++ apps/api/src/ponds/ponds.module.ts | 3 + apps/api/src/ponds/ponds.service.ts | 24 ++-- apps/api/src/quotas/quota.service.db.test.ts | 130 ++++++++++++++++++ apps/api/src/quotas/quota.service.ts | 105 ++++++++++++++ apps/api/src/quotas/quotas.module.ts | 9 ++ .../src/settings/instance-settings.service.ts | 15 ++ packages/shared/i18n/de/errors.json | 1 + packages/shared/i18n/en/errors.json | 1 + packages/shared/src/index.ts | 1 + packages/shared/src/quotas.ts | 14 ++ 14 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 apps/api/prisma/migrations/20260705185146_quotas/migration.sql create mode 100644 apps/api/src/quotas/quota.service.db.test.ts create mode 100644 apps/api/src/quotas/quota.service.ts create mode 100644 apps/api/src/quotas/quotas.module.ts create mode 100644 packages/shared/src/quotas.ts diff --git a/apps/api/prisma/migrations/20260705185146_quotas/migration.sql b/apps/api/prisma/migrations/20260705185146_quotas/migration.sql new file mode 100644 index 0000000..0f65e26 --- /dev/null +++ b/apps/api/prisma/migrations/20260705185146_quotas/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index cc446e1..3fe21a5 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -72,12 +72,48 @@ model Pond { deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") - owner User @relation(fields: [ownerId], references: [id]) + 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:" later; `credential` holds the Argon2id hash for /// password identities. diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 7339b55..3ad5493 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -91,6 +91,24 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise { }, }); } + // 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 }, + }); } } diff --git a/apps/api/src/ponds/ponds.e2e.db.test.ts b/apps/api/src/ponds/ponds.e2e.db.test.ts index b50b71d..6af6aa0 100644 --- a/apps/api/src/ponds/ponds.e2e.db.test.ts +++ b/apps/api/src/ponds/ponds.e2e.db.test.ts @@ -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') diff --git a/apps/api/src/ponds/ponds.module.ts b/apps/api/src/ponds/ponds.module.ts index 5e55616..b6df5eb 100644 --- a/apps/api/src/ponds/ponds.module.ts +++ b/apps/api/src/ponds/ponds.module.ts @@ -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], diff --git a/apps/api/src/ponds/ponds.service.ts b/apps/api/src/ponds/ponds.service.ts index 37524a9..714eb38 100644 --- a/apps/api/src/ponds/ponds.service.ts +++ b/apps/api/src/ponds/ponds.service.ts @@ -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,14 +62,20 @@ export class PondsService { } async createShared(owner: User, input: CreatePondInput): Promise { - const pond = await this.prisma.pond.create({ - data: { - slug: await this.generateUniqueSlug(input.name, owner.username), - name: input.name, - description: input.description, - type: 'SHARED', - ownerId: owner.id, - }, + 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, + 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); diff --git a/apps/api/src/quotas/quota.service.db.test.ts b/apps/api/src/quotas/quota.service.db.test.ts new file mode 100644 index 0000000..56f12a1 --- /dev/null +++ b/apps/api/src/quotas/quota.service.db.test.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/apps/api/src/quotas/quota.service.ts b/apps/api/src/quotas/quota.service.ts new file mode 100644 index 0000000..0bc5496 --- /dev/null +++ b/apps/api/src/quotas/quota.service.ts @@ -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 = { + 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 { + 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 { + // ::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 { + 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 { + 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 { + const row = await this.prisma.quotaOverride.findUnique({ + where: { subjectType_subjectId_quotaKey: { subjectType, subjectId, quotaKey } }, + }); + return row === null ? null : Number(row.value); + } +} diff --git a/apps/api/src/quotas/quotas.module.ts b/apps/api/src/quotas/quotas.module.ts new file mode 100644 index 0000000..107880c --- /dev/null +++ b/apps/api/src/quotas/quotas.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { QuotaService } from './quota.service'; + +@Module({ + providers: [QuotaService], + exports: [QuotaService], +}) +export class QuotasModule {} diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index c4e6c0d..3d3be32 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -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; diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index 1bbc320..d0adcbd 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -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.", diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index 66b6ea2..af87ad4 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -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.", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fcfe891..010c464 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,3 +4,4 @@ export * from './env'; export * from './health'; export * from './i18n-tools'; export * from './ponds'; +export * from './quotas'; diff --git a/packages/shared/src/quotas.ts b/packages/shared/src/quotas.ts new file mode 100644 index 0000000..369cc10 --- /dev/null +++ b/packages/shared/src/quotas.ts @@ -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];