/** * Typed API client for the FamilySync backend. * * credentials: 'include' is required so the OIDC session cookie is sent with * every cross-origin request (Vite dev proxy routes to :3000; production is * same-origin via Pangolin). * * Auth note: the OIDC guard's 302 to Authelia is CORS-blocked for fetch/XHR — * browsers do not follow cross-origin redirects from XHR to an external IdP. * Re-authentication therefore requires a TOP-LEVEL navigation to /api/login * (see apps/pwa/src/lib/loginRedirect.ts). fetchMe and other fetch calls here * are pure data fetches; they throw on non-ok responses and leave the redirect * decision to the caller (CalendarShell via maybeRedirectToLogin). * * D-11 (Plan 06-05): All fetch wrappers now use redirect:'manual' and throw a * typed SessionExpiredError on 401 / opaqueredirect. The global QueryCache / * MutationCache error handler in main.tsx catches this class and arms the * session-expiry interstitial. A generic Error is still thrown for other non-ok * statuses so error UI can distinguish auth failures from transient errors. */ // ── Auth error ──────────────────────────────────────────────────────────── /** * Typed error thrown by all fetch wrappers when the server returns a 401 or * an opaque redirect (the OIDC guard's 302 to Authelia surfaced as * res.type==='opaqueredirect' via redirect:'manual'). * * Object.setPrototypeOf is required so instanceof checks work correctly after * TypeScript compilation to ES5 / CommonJS, where extending built-in Error * breaks the prototype chain. */ export class SessionExpiredError extends Error { readonly name = 'SessionExpiredError'; constructor() { super('Session expired — re-authentication required'); Object.setPrototypeOf(this, SessionExpiredError.prototype); } } /** * Unified auth-response checker applied to every fetch in this module. * * - 401 or opaqueredirect → throws SessionExpiredError (caught by global QueryCache handler) * - other non-ok → throws a generic Error (distinguishable from auth failures) * - ok → no-op (caller proceeds to parse body) * * @param res The fetch Response object * @param label A short human-readable description for the generic error message */ function handleAuthResponse(res: Response, label: string): void { if (res.type === 'opaqueredirect' || res.status === 401) { throw new SessionExpiredError(); } if (!res.ok) { throw new Error(`${label} failed: ${res.status}`); } } // ── /api/auth/* (Phase 19 — local auth) ─────────────────────────────────── /** * Typed error thrown by fetchLocalLogin when the server returns 401/429/423/5xx. * * Codes: * 'invalid' — 401: incorrect username or password * 'rate-limit' — 429: too many attempts within the rate window * 'locked' — 423: account locked (persistent lockout) * 'server' — 5xx or network: transient server error * * Object.setPrototypeOf is required so instanceof checks work correctly after * TypeScript compilation to ES5 / CommonJS (mirrors SessionExpiredError). */ export class LoginError extends Error { readonly name = 'LoginError'; constructor(public readonly code: 'invalid' | 'rate-limit' | 'locked' | 'server') { super(`Login failed: ${code}`); Object.setPrototypeOf(this, LoginError.prototype); } } /** * GET /api/auth/mode — pre-auth endpoint, no session required. * Returns whether local-auth and/or OIDC are enabled. * staleTime: 60_000 in App.tsx authModeQuery. */ export async function fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnabled: boolean }> { const res = await fetch('/api/auth/mode'); if (!res.ok) { throw new Error(`fetchAuthMode failed: ${res.status}`); } return res.json() as Promise<{ localEnabled: boolean; oidcEnabled: boolean }>; } /** * POST /api/auth/local/login — submit username/password credentials. * * Maps status codes to typed LoginError: * 401 → LoginError('invalid') — incorrect username or password * 429 → LoginError('rate-limit') — too many attempts * 423 → LoginError('locked') — account locked * other non-ok → LoginError('server') * * Throws nothing on 200 OK — the local-session cookie is set by the server. */ export async function fetchLocalLogin(body: { username: string; password: string; }): Promise { const res = await fetch('/api/auth/local/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', redirect: 'manual', body: JSON.stringify(body), }); if (res.status === 401) throw new LoginError('invalid'); if (res.status === 429) throw new LoginError('rate-limit'); if (res.status === 423) throw new LoginError('locked'); if (!res.ok) throw new LoginError('server'); } /** * POST /api/auth/local/logout — clear the local-session cookie. */ export async function fetchLocalLogout(): Promise { const res = await fetch('/api/auth/local/logout', { method: 'POST', credentials: 'include', redirect: 'manual', }); if (!res.ok && res.type !== 'opaqueredirect') { throw new Error(`fetchLocalLogout failed: ${res.status}`); } } /** * POST /api/me/password — self-service password change (Phase 19, Surface 12). * Requires the user's current password and a new password (min 8 chars). * * Status codes: * 403 → wrong current password (throws Error('wrong-current')) — NOT a session expiry * 401 / opaqueredirect → genuine session expiry (throws SessionExpiredError) * other non-ok → generic error (throws Error('server')) * * CR-03: the server returns 403 (not 401) for an incorrect current password so this * client can distinguish an in-app authorization failure from a real session expiry. * Treating that case as 401 would route it to the global MutationCache session-expiry * handler and forcibly log the user out for a simple mistyped password. */ export async function fetchChangePassword(body: { currentPassword: string; newPassword: string; }): Promise { const res = await fetch('/api/me/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', redirect: 'manual', body: JSON.stringify(body), }); // 403 → wrong current password (in-app). Check BEFORE the 401 session-expiry branch. if (res.status === 403) throw new Error('wrong-current'); if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError(); if (!res.ok) { const detail = (await res.json().catch(() => ({}))) as { code?: string }; throw new Error(detail.code ?? 'server'); } } /** * POST /api/admin/members — create a new local member account (Phase 19, Surface 11A). * Admin-only; server enforces requireAdmin. * * Request contract: the server's createMemberSchema requires * { displayName, username, initialPassword } * (see apps/api/src/routes/admin.ts). The caller-facing `password` field is mapped to * `initialPassword` here so the request validates server-side. * * Status codes: * 409 → username already taken (throws Error with message 'conflict') * other non-ok → generic error (throws Error('server')) */ export async function fetchCreateMember(body: { displayName: string; username: string; password: string; }): Promise { const res = await fetch('/api/admin/members', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', redirect: 'manual', // CR-02: the server expects `initialPassword`, not `password`. Send the field it // validates against — otherwise Zod rejects every create with a generic 400. body: JSON.stringify({ displayName: body.displayName, username: body.username, initialPassword: body.password, }), }); if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError(); // 409 → username conflict. The server returns { error: 'Username already in use' } (no // `code` field), so map the status to the 'conflict' sentinel the AdminPage handler expects. if (res.status === 409) throw new Error('conflict'); if (!res.ok) { const detail = (await res.json().catch(() => ({}))) as { code?: string }; throw new Error(detail.code ?? 'server'); } } /** * POST /api/admin/members/:id/password — admin reset of a member's password (Surface 11B). * Admin-only; server enforces requireAdmin. (Route is registered as `/members/:id/password` * in apps/api/src/routes/admin.ts — must match exactly or the sheet 404s.) */ export async function fetchAdminResetPassword( memberId: number, newPassword: string, ): Promise { const res = await fetch(`/api/admin/members/${memberId}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', redirect: 'manual', body: JSON.stringify({ newPassword }), }); if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError(); if (!res.ok) { throw new Error(`fetchAdminResetPassword failed: ${res.status}`); } } /** * POST /api/me/link-oidc — initiate the OIDC-link flow for the current local user (Surface 13). * * The server returns the OIDC authorization endpoint URL (with a signed `state` parameter * encoding the linkUserId claim) to begin the authorization-code flow. The caller should * follow it via top-level navigation (window.location.href = authorizationUrl) when present. * * authorizationUrl is null when OIDC is not configured in env (the server cannot build the * URL); callers MUST handle that case and surface an error instead of navigating to null. * The server contract is { signedState, authorizationUrl } (see apps/api/src/routes/me.ts). */ export async function fetchLinkOidc(): Promise<{ authorizationUrl: string | null }> { const res = await fetch('/api/me/link-oidc', { method: 'POST', credentials: 'include', redirect: 'manual', }); if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError(); if (!res.ok) { throw new Error(`fetchLinkOidc failed: ${res.status}`); } return res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>; } // ── /api/me ──────────────────────────────────────────────────────────────── export interface MeUser { id: number; displayName: string | null; color: string; isAdmin: boolean; // from users.is_admin — UX gating only (D-03); server enforces 403 on /api/admin/* needsProviderSetup: boolean; // true when no member_credentials row exists for this user hasLocalCredential: boolean; // true when a local_credentials row exists for this user (Phase 19) } export interface MeResponse { user: MeUser; } export async function fetchMe(): Promise { // redirect: 'manual' is critical. The OIDC guard answers an unauthenticated // request with a 302 to Authelia (cross-origin). With the default // redirect: 'follow', the browser follows that credentialed cross-origin // redirect and the fetch HANGS (never resolves, never rejects) — leaving the // query stuck "loading" so the SPA spins forever and the auth-redirect below // never fires. With 'manual', the 302 comes back as an opaqueredirect // (res.type === 'opaqueredirect', res.status === 0) that we detect immediately. const res = await fetch('/api/me', { credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, 'GET /api/me'); return res.json() as Promise; } // ── /api/events (windowed — Phase 2) ───────────────────────────────────── /** * A single concrete occurrence of a calendar event. * * Mirrors the CalendarOccurrence shape produced by the backend's * expandOccurrences() helper (apps/api/src/broker/expand.ts). * * calendarId and ownerUserId are both present: * calendarId — DB calendar-row id (do NOT use for Schedule-X routing) * ownerUserId — DB user id (use for Schedule-X calendarId routing) * isShared — true when this event belongs to the shared-family calendar * * hydrateEvents() uses isShared/ownerUserId — never String(calendarId) — * to build the Schedule-X calendarId that keys into buildCalendarConfig(). */ export interface CalendarOccurrence { id: string; // `ev--` — stable identity (server: expand.ts makeOccurrenceId) uid: string; calendarId: number; // DB calendar-row id — do NOT use for SX calendarId routing calendarName: string; ownerUserId: number; // DB user id — the correct Schedule-X routing key /** * Display name of the calendar owner (users.displayName from the API). * Null when the user has not configured a display name. * Popover renders: isShared ? 'Family' : (ownerName ?? calendarName) */ ownerName: string | null; color: string; // hex from users.color or shared-family constant isShared: boolean; // true → 'shared' slot; false → String(ownerUserId) slot title: string; start: string; // 'YYYY-MM-DD' for allDay:true; ISO 8601 with IANA tz for timed end: string; allDay: boolean; location: string | null; description: string | null; /** * True when this occurrence belongs to a recurring series (has an RRULE). * Mirrors CalendarOccurrence.hasRrule in apps/api/src/broker/expand.ts — must * stay in sync with the server type (Pitfall 4 — atomic mirror, Plan 06-05). */ hasRrule: boolean; /** * Per-event reminder lead in minutes. NULL = no reminder. 0 = same-day all-day * (fire 9 AM on event date). Positive integer = N minutes before event start. * D-06: NULL and 0 are semantically distinct. * Mirrors CalendarOccurrence.reminderLeadMinutes in apps/api/src/broker/expand.ts * (atomic mirror, Plan 11-03). */ reminderLeadMinutes: number | null; /** * True when the event's alarm is custom/absolute/multi-VALARM (not reducible to a * single before-event lead). When true, reminderLeadMinutes is always null and the * form must initialize to '__custom__' to preserve the VALARM on edit (CR-01, Plan 11-05). * Mirrors CalendarOccurrence.reminderIsCustom in apps/api/src/broker/expand.ts * (atomic mirror, Plan 11-05). */ reminderIsCustom: boolean; } export interface OccurrencesResponse { occurrences: CalendarOccurrence[]; } /** * Fetch windowed calendar occurrences. * * The ?start=&end= window is mandatory — an unwindowed call would expand 500+ * cached events with all their recurring occurrences (RESEARCH.md Pitfall 5). * * @param start ISO date string 'YYYY-MM-DD' — window start (inclusive) * @param end ISO date string 'YYYY-MM-DD' — window end (exclusive) */ export async function fetchEvents(start: string, end: string): Promise { const res = await fetch(`/api/events?start=${start}&end=${end}`, { credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, 'GET /api/events'); return res.json() as Promise; } // Phase 1 legacy types (CalendarEvent, EventsResponse, fetchEventsLegacy) removed in Plan 05 // when the Phase 1 broker-proof component was retired. // ── /api/events (write — Plan 03-05) ───────────────────────────────────────── /** * Recurrence presets supported by the EventForm. * Maps 1:1 to the RRULE frequency values the API accepts. */ export type RecurrencePreset = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; /** * Payload for creating or updating a calendar event. * Mirrors the Zod schema on POST /api/events/create and PATCH /api/events/:uid/edit. */ export interface CreateEventPayload { title: string; allDay: boolean; start: string; // 'YYYY-MM-DD' for allDay; ISO 8601 for timed end: string; // same format as start // WR-01: optional. CREATE always sends it; EDIT omits it so the API/worker preserve // the event's existing RRULE (the occurrence contract does not expose recurrence, so // the form cannot echo it back without silently resetting it to 'none'). recurrence?: RecurrencePreset; /** * RRULE UNTIL date (D-06). ISO 'YYYY-MM-DD' string. Only sent when recurrence !== 'none' * and the user selects the "On date" bound. Mutually exclusive with recurrenceCount. * The outbox worker converts this to RRULE UNTIL format (DATE for all-day, DATETIME UTC for timed). */ recurrenceUntil?: string; /** * RRULE COUNT (D-06). Integer >= 1. Only sent when recurrence !== 'none' and the user * selects the "After N times" bound. Mutually exclusive with recurrenceUntil. */ recurrenceCount?: number; location?: string; description?: string; /** * Per-event reminder lead in minutes. * absent/undefined = no-change (edit omits field so server preserves existing VALARM, D-08) * null = explicit "None" (clear any VALARM) * 0 = same-day all-day (fire 9 AM on event date, D-05) * positive integer = N minutes before event start */ reminderLeadMinutes?: number | null; calendarUrl?: string; // omit to use the member's default writable calendar (D-01) } /** Response from POST /api/events/create and PATCH /api/events/:uid/edit */ export interface CreateEventResponse { uid: string; } /** * A writable calendar returned by GET /api/events/writable-calendars. * The server is the authoritative source of the writable set (D-03). * The client never derives writability — it reads this endpoint verbatim. */ export interface WritableCalendar { url: string; displayName: string; color: string; isShared: boolean; } /** * Create a new calendar event. * * POSTs to /api/events/create and returns immediately with 202 + uid. * The server enqueues the write to CalDAV asynchronously (D-05/D-12). */ export async function createEvent(payload: CreateEventPayload): Promise { const res = await fetch('/api/events/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', redirect: 'manual', body: JSON.stringify(payload), }); handleAuthResponse(res, 'POST /api/events/create'); return res.json() as Promise; } /** * Update an existing calendar event. * * PATCHes /api/events/:uid/edit with the updated payload. * Returns 202 + uid; the write is enqueued asynchronously (D-05). */ export async function updateEvent( uid: string, payload: CreateEventPayload, ): Promise { const res = await fetch(`/api/events/${uid}/edit`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', redirect: 'manual', body: JSON.stringify(payload), }); handleAuthResponse(res, `PATCH /api/events/${uid}/edit`); return res.json() as Promise; } /** * Delete a calendar event. * * DELETEs /api/events/:uid; the API enqueues the delete to the outbox (D-05). * Returns 202 Accepted (async). Throws on any non-ok response. */ export async function deleteEvent(uid: string): Promise { const res = await fetch(`/api/events/${uid}`, { method: 'DELETE', credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, `DELETE /api/events/${uid}`); } // ── /api/events/sync-status (Plan 03-06) ───────────────────────────────────── /** * Status values for an outbox write operation. * Mirrors the calendarOutbox.status enum on the server. */ export type SyncStatusValue = 'pending' | 'done' | 'failed' | 'dead'; /** * Response from GET /api/events/sync-status?uid= * The server returns the current outbox status for the given UID + member. */ export interface SyncStatus { uid: string; status: SyncStatusValue; /** Present on failed status — may contain '412' prefix for conflict detection. */ error?: string; } /** * Poll the sync-status for a specific event UID. * * Used by SyncStateToast to track pending → done | failed | dead transitions. * The server filters by the current member so no cross-member leakage (T-03-19). */ export async function fetchSyncStatus(uid: string): Promise { const res = await fetch(`/api/events/sync-status?uid=${uid}`, { credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, 'GET /api/events/sync-status'); return res.json() as Promise; } /** * Fetch the authoritative list of writable calendars for the current member. * * The server owns the D-03 writable set (WHERE userId=currentUser.id OR isShared=1). * The client NEVER derives writability — it reads this endpoint verbatim. * Drives the calendar picker visibility (D-02: hidden when only 1 writable calendar). */ export async function fetchWritableCalendars(): Promise { const res = await fetch('/api/events/writable-calendars', { credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, 'GET /api/events/writable-calendars'); const body = (await res.json()) as { calendars: WritableCalendar[] }; return body.calendars; } // ── /api/admin/* (Phase 10, Plan 04) ───────────────────────────────────────── /** * A member row as returned by GET /api/admin/members. * Matches the Plan-03 shape: { id, displayName, color, hasCredential }. */ export interface AdminMember { id: number; displayName: string | null; color: string; hasCredential: boolean; hasLocalCredential: boolean; // true when a local_credentials row exists for this member (Phase 19) } export interface AdminMembersResponse { members: AdminMember[]; } /** * Payload for POST /api/admin/credentials (admin-managed rotation). * NOTE: includes userId (the member being rotated) — admin-scoped. * The app password MUST NOT be logged or stored beyond the in-flight request body (T-10-15). */ export interface SaveCredentialPayload { userId: number; providerType: 'caldav'; fastmailEmail: string; appPassword: string; } /** * A synced calendar row as returned by GET /api/admin/calendars. * Matches the Plan-03 shape: { id, displayName, isShared }. */ export interface AdminCalendar { id: number; displayName: string; isShared: boolean; } export interface AdminCalendarsResponse { calendars: AdminCalendar[]; } /** * Payload for POST /api/me/credential (self-service, member-scoped). * NOTE: no userId field — the server resolves userId from the session (Pitfall 6 / T-10-12). * The app password MUST NOT be logged or stored beyond the in-flight request body (T-10-15). */ export interface SaveMyCredentialPayload { providerType: 'caldav'; fastmailEmail: string; appPassword: string; } /** * Fetch the list of all members with their credential status. * Admin-only: the server enforces requireAdmin (403 for non-admins). */ export async function fetchAdminMembers(): Promise { const res = await fetch('/api/admin/members', { credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, 'GET /api/admin/members'); return res.json() as Promise; } /** * Save (add or rotate) a credential for any member. * Admin-only: requires userId in payload; server enforces requireAdmin. * The app password is sent in the request body and NEVER stored client-side. */ export async function saveCredential(payload: SaveCredentialPayload): Promise { const res = await fetch('/api/admin/credentials', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', redirect: 'manual', body: JSON.stringify(payload), }); handleAuthResponse(res, 'POST /api/admin/credentials'); } /** * Fetch the list of synced calendars with their shared status. * Admin-only: the server enforces requireAdmin (403 for non-admins). */ export async function fetchAdminCalendars(): Promise { const res = await fetch('/api/admin/calendars', { credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, 'GET /api/admin/calendars'); return res.json() as Promise; } /** * Set the shared calendar (ADMIN-02: exclusive single-select, D-06). * Admin-only: the server enforces requireAdmin. * Clears is_shared on all other calendars and sets it on the given id. */ export async function setSharedCalendar(calendarId: number): Promise { const res = await fetch(`/api/admin/calendars/${calendarId}/shared`, { method: 'PUT', credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, `PUT /api/admin/calendars/${calendarId}/shared`); } /** * Response shape for GET /api/admin/config/timezone. * isExplicitlySet is false when the household timezone has not been saved yet * (the server returns the process.env.TZ / Intl fallback in that case — D-06). */ export interface AdminTimezoneResponse { timezone: string; isExplicitlySet: boolean; } /** * Fetch the current household timezone setting. * Admin-only: the server enforces requireAdmin (403 for non-admins). */ export async function fetchAdminTimezone(): Promise { const res = await fetch('/api/admin/config/timezone', { credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, 'GET /api/admin/config/timezone'); return res.json() as Promise; } /** * Set the household timezone. * Admin-only: the server enforces requireAdmin and IANA validation (Plan 02). * @param timezone A valid IANA timezone identifier (e.g. 'America/Chicago'). */ export async function setAdminTimezone(timezone: string): Promise { const res = await fetch('/api/admin/config/timezone', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', redirect: 'manual', body: JSON.stringify({ timezone }), }); handleAuthResponse(res, 'PUT /api/admin/config/timezone'); } /** * Self-service: save (add) the current member's own credential. * Member-scoped: NO userId in payload — server resolves from session (Pitfall 6 / T-10-12). * The app password is sent in the request body and NEVER stored client-side. * On success, /api/me re-fetched via ['me'] cache invalidation → needsProviderSetup becomes false. */ export async function saveMyCredential(payload: SaveMyCredentialPayload): Promise { const res = await fetch('/api/me/credential', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', redirect: 'manual', body: JSON.stringify(payload), }); handleAuthResponse(res, 'POST /api/me/credential'); } // ── /api/setup/* (Phase 12 — initial setup wizard) ─────────────────────────── /** * Response from GET /api/setup/status. * setupComplete: false → the wizard has not been completed; redirect to /setup. * setupComplete: true → normal app boot proceeds. */ export interface SetupStatusResponse { setupComplete: boolean; /** * Non-secret database name (process.env.DB_NAME) surfaced for the wizard's * read-only "database connection verified" referent (gap 3). Never includes * DB_HOST/DB_USER/DB_PASSWORD. */ dbName?: string | null; } /** * Payload for POST /api/setup/config. * Collects non-secret runtime config written to the app_config table (D-02). * No secrets — VAPID private key and encryption key stay in Docker env. * * Field names match the API's configSchema exactly (camelCase). * API contract: { oidcIssuer, oidcClientId, vapidPublicKey, appExternalUrl } */ export interface SetupConfigPayload { appExternalUrl: string; oidcIssuer: string; oidcClientId: string; vapidPublicKey: string; } /** * Payload for POST /api/setup/credential. * The Fastmail app password is sent once and NEVER stored client-side (T-12-15). */ export interface SetupCredentialPayload { fastmailEmail: string; appPassword: string; } /** * GET /api/setup/status — unauthenticated; fetched before the OIDC guard. * staleTime: 0 — always fresh (the gate must not be stale; mirrors D-10 spirit). */ export async function fetchSetupStatus(): Promise { const res = await fetch('/api/setup/status', { // No credentials: 'include' needed — this is a pre-auth endpoint. // No redirect: 'manual' — setup endpoints never redirect to Authelia. }); if (!res.ok) { throw new Error(`GET /api/setup/status failed: ${res.status}`); } return res.json() as Promise; } /** * POST /api/setup/config — writes non-secret config values to app_config. * Must be called before the validation step so the OIDC issuer is persisted * for the server-side discovery check. */ export async function postSetupConfig(payload: SetupConfigPayload): Promise { const res = await fetch('/api/setup/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (res.status === 423) { throw new SetupAlreadyLockedError(); } if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string | { name?: string; issues?: Array<{ message: string }> }; }; // body.error may be a ZodError object ({ name: "ZodError", issues: [...] }) // rather than a plain string — extract the first issue message to avoid // "[object Object]" appearing in the UI (BUG 2 fix). let message: string; if (typeof body.error === 'string') { message = body.error; } else if ( body.error && typeof body.error === 'object' && Array.isArray((body.error as { issues?: unknown[] }).issues) && (body.error as { issues: Array<{ message: string }> }).issues.length > 0 ) { message = (body.error as { issues: Array<{ message: string }> }).issues[0].message; } else { message = `POST /api/setup/config failed: ${res.status}`; } throw new Error(message); } } /** * POST /api/setup/validate/db — confirms DB connectivity. * Returns void on success; throws on failure with a typed message. */ export async function validateSetupDb(): Promise { const res = await fetch('/api/setup/validate/db', { method: 'POST' }); if (res.status === 423) throw new SetupAlreadyLockedError(); if (!res.ok) { throw new Error( 'Cannot reach the database. Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD in your Docker environment and try again.', ); } } /** * POST /api/setup/validate/oidc — fetches the OIDC discovery document. * Requires that POST /api/setup/config has already been called with a valid oidc_issuer. */ export async function validateSetupOidc(): Promise { const res = await fetch('/api/setup/validate/oidc', { method: 'POST' }); if (res.status === 423) throw new SetupAlreadyLockedError(); if (!res.ok) { throw new Error( 'OIDC discovery failed. Check the issuer URL and that Authelia is reachable from the server.', ); } } /** * POST /api/setup/validate/vapid — structural check of the VAPID key pair. */ export async function validateSetupVapid(): Promise { const res = await fetch('/api/setup/validate/vapid', { method: 'POST' }); if (res.status === 423) throw new SetupAlreadyLockedError(); if (!res.ok) { throw new Error( 'VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.', ); } } /** * POST /api/setup/credential — validates the Fastmail app password against * CalDAV PROPFIND and inserts the local wizard user + credential row. * The password is never stored client-side (T-12-15). */ export async function postSetupCredential(payload: SetupCredentialPayload): Promise { const res = await fetch('/api/setup/credential', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fastmailEmail: payload.fastmailEmail, appPassword: payload.appPassword, // providerType omitted — not in credentialSchema; server hard-codes 'caldav' }), }); if (res.status === 423) throw new SetupAlreadyLockedError(); if (!res.ok) { throw new Error( "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again.", ); } } /** * POST /api/setup/complete — flips setup_complete in app_config. * Returns 200 on first call; throws SetupAlreadyLockedError on 423. */ export async function postSetupComplete(): Promise { const res = await fetch('/api/setup/complete', { method: 'POST' }); if (res.status === 423) throw new SetupAlreadyLockedError(); if (!res.ok) { throw new Error(`POST /api/setup/complete failed: ${res.status}`); } } /** * Thrown when any setup endpoint returns 423 (setup already locked). * SetupPage catches this and renders Surface 8 (Already Locked screen). */ export class SetupAlreadyLockedError extends Error { readonly name = 'SetupAlreadyLockedError'; constructor() { super('Setup is already complete and locked.'); Object.setPrototypeOf(this, SetupAlreadyLockedError.prototype); } }