Files
HealthLog/src/lib/validations/admin.ts
T
Marc-André Bombeck d0b45e36b3 feat(integrations): remove the moodLog integration
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.

Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.

Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.

Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.

`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
2026-07-25 11:36:18 +02:00

107 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(),
// 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();