Files
HealthLog/src/lib/validations/admin.ts
T
Marc-André Bombeck 155b74ccf4 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.
2026-07-07 11:06:04 +02:00

108 lines
4.2 KiB
TypeScript

import { z } from "zod/v4";
import { isPublicUrl } from "@/lib/validations/notifications";
/**
* Schema for admin settings update (PUT /api/admin/settings).
* All fields are optional — partial updates are supported.
*/
export const adminSettingsSchema = z
.object({
registrationEnabled: z.boolean().optional(),
// v1.23 — instance-wide "require a second factor" policy.
mfaRequired: z.boolean().optional(),
defaultLocale: z.enum(["de", "en"]).optional(),
telegramGlobal: z.boolean().optional(),
ntfyGlobal: z.boolean().optional(),
webPushGlobal: z.boolean().optional(),
webPushVapidPublicKey: z.string().optional(),
webPushVapidSubject: z
.string()
.refine((v) => !v.trim() || /^mailto:.+@.+$/.test(v.trim()), {
message:
"webPushVapidSubject must be in mailto:address@example.com format",
})
.optional(),
webPushVapidPrivateKey: z.string().optional(),
clearWebPushVapidPrivateKey: z.boolean().optional(),
apiGlobal: z.boolean().optional(),
umamiEnabled: z.boolean().optional(),
umamiScriptUrl: z
.string()
.refine(
(v) => {
const trimmed = v.trim();
if (!trimmed) return true;
// The Umami script proxy fetches this URL server-side and
// re-serves the body from the app origin under a strict CSP
// — a private-IP target would be an SSRF channel. Match the
// posture every other admin-supplied outbound URL takes.
return isPublicUrl(trimmed);
},
{
message:
"Umami script URL must be a public HTTP(S) endpoint (no private / loopback / metadata addresses)",
},
)
.optional(),
umamiWebsiteId: z.string().optional(),
glitchtipEnabled: z.boolean().optional(),
glitchtipDsn: z
.string()
.refine(
(v) => {
const trimmed = v.trim();
if (!trimmed) return true;
// Glitchtip DSNs are `https://<key>@host/projectid`. The
// userinfo is the auth material; the SSRF surface is the
// host — same `isPublicUrl` posture as the Umami URL.
return isPublicUrl(trimmed);
},
{
message:
"Glitchtip DSN must point to a public HTTP(S) endpoint (no private / loopback / metadata addresses)",
},
)
.optional(),
glitchtipEnvironment: z.string().optional(),
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
// guards the shape + length.
defaultUserTimezone: z.string().max(64).optional(),
// v1.4.31 — assistant-surface operator feature flags. Master
// kills every sub-flag; sub-flags carve specific surfaces. The
// dedicated admin endpoint at
// /api/admin/settings/assistant-flags also accepts these via
// its own schema; this entry exists so the generic
// /api/admin/settings PUT path can still carry the values for
// operators who script their settings updates over a single
// route.
assistantEnabled: z.boolean().optional(),
assistantCoachEnabled: z.boolean().optional(),
assistantBriefingEnabled: z.boolean().optional(),
assistantInsightStatusEnabled: z.boolean().optional(),
assistantCorrelationsEnabled: z.boolean().optional(),
assistantHealthScoreExplainerEnabled: z.boolean().optional(),
})
.strict();