feat(admin): expose the two document-vault limits

The admin settings GET/PUT carry documentMaxFileBytes (1 MiB – 100 MiB;
the hard ceiling exists because single-shot GCM + bounded in-memory reads
are load-bearing past it, and the upload-time resolver clamps again as
defence-in-depth) and documentQuotaBytes (10 MiB – 1 TiB instance
default). The admin user PUT accepts a nullable per-user quota override;
null clears it back to the instance default. Cookie-only requireAdmin()
guards both surfaces as before — Bearer tokens cannot reach them.

Task: W1-T13.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 11:06:04 +02:00
1 parent de9e528614
commit 155b74ccf4
3 files changed
+62 -1

No files matched your search

+20
View File
@@ -51,6 +51,13 @@ export const GET = apiHandler(async () => {
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
moodLogGlobal: settings?.moodLogGlobal ?? true,
// Document vault limits (per-file cap + per-user quota default). BigInt
// column → Number for the JSON envelope (values are far below 2^53).
documentMaxFileBytes: settings?.documentMaxFileBytes ?? 26_214_400,
documentQuotaBytes:
settings?.documentQuotaBytes !== undefined
? Number(settings.documentQuotaBytes)
: 1_073_741_824,
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the
// resolver"; surfacing the raw value lets the admin UI render
// an empty picker placeholder until they opt in.
@@ -176,6 +183,17 @@ export const PUT = apiHandler(async (request: NextRequest) => {
auditDetails.reminderMissedMinutes = data.reminderMissedMinutes;
}
// Document vault limits (schema already bounds both; the upload-time
// resolver clamps the cap to the hard ceiling again as defence-in-depth).
if (data.documentMaxFileBytes !== undefined) {
updates.documentMaxFileBytes = data.documentMaxFileBytes;
auditDetails.documentMaxFileBytes = data.documentMaxFileBytes;
}
if (data.documentQuotaBytes !== undefined) {
updates.documentQuotaBytes = BigInt(data.documentQuotaBytes);
auditDetails.documentQuotaBytes = data.documentQuotaBytes;
}
// v1.4.25 W7 — server-default timezone for new signups.
// Empty string clears the override (resolver falls back to
// Europe/Berlin); a non-empty string must pass Intl validation
@@ -240,6 +258,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
moodLogGlobal: settings.moodLogGlobal,
documentMaxFileBytes: settings.documentMaxFileBytes,
documentQuotaBytes: Number(settings.documentQuotaBytes),
defaultUserTimezone: settings.defaultUserTimezone,
});
});
+25 -1
View File
@@ -19,6 +19,16 @@ const updateUserSchema = z.object({
// v1.23 — per-user "require a second factor" override. Effective requirement
// is the OR of this and the instance-wide AppSettings.mfaRequired policy.
mfaEnforced: z.boolean().optional(),
// Document vault — per-user storage-quota override in bytes. Null clears
// the override (the instance default applies again). Same bounds as the
// instance-wide setting.
documentQuotaBytes: z
.number()
.int()
.min(10_485_760)
.max(1_099_511_627_776)
.nullable()
.optional(),
});
export const PUT = apiHandler(
@@ -70,6 +80,12 @@ export const PUT = apiHandler(
...(parsed.data.mfaEnforced !== undefined && {
mfaEnforced: parsed.data.mfaEnforced,
}),
...(parsed.data.documentQuotaBytes !== undefined && {
documentQuotaBytes:
parsed.data.documentQuotaBytes === null
? null
: BigInt(parsed.data.documentQuotaBytes),
}),
},
select: {
id: true,
@@ -78,6 +94,7 @@ export const PUT = apiHandler(
role: true,
createdAt: true,
mfaEnforced: true,
documentQuotaBytes: true,
},
});
@@ -87,6 +104,13 @@ export const PUT = apiHandler(
details: { targetUserId: id, changes: parsed.data },
});
return apiSuccess(updatedUser);
return apiSuccess({
...updatedUser,
// BigInt → Number for the JSON envelope; null = no override.
documentQuotaBytes:
updatedUser.documentQuotaBytes === null
? null
: Number(updatedUser.documentQuotaBytes),
});
},
);
+17
View File
@@ -67,6 +67,23 @@ export const adminSettingsSchema = z
reminderLateMinutes: z.number().int().min(15).max(480).optional(),
reminderMissedMinutes: z.number().int().min(30).max(720).optional(),
moodLogGlobal: z.boolean().optional(),
// Document vault — the two admin-tunable limits. The per-file cap is
// bounded by the hard 100 MiB ceiling (single-shot GCM + bounded
// in-memory reads are load-bearing past that; the runtime resolver
// clamps again as defence-in-depth). The quota accepts up to 1 TiB —
// BYTEA rides pg_dump, so anything larger is an operator error.
documentMaxFileBytes: z
.number()
.int()
.min(1_048_576)
.max(104_857_600)
.optional(),
documentQuotaBytes: z
.number()
.int()
.min(10_485_760)
.max(1_099_511_627_776)
.optional(),
// v1.4.25 W7 — server-wide default timezone for new signups
// that don't carry a browser-detected zone. The route validates
// against Intl.supportedValuesOf at runtime; this schema just