Files
familysync/apps/pwa/src/api/client.ts
T
Lucas BergerandClaude Opus 4.8 b6490feff4
CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
fix(19): satisfy CI fast-checks + secret scan
Lint (eslint --max-warnings 0):
- index.ts: disable no-unsafe-argument on the type-only Context mismatch when
  delegating to the OIDC handler inside the local-session skip wrapper
- localAuth.ts: handleLogout is sync (no await) — drop async (require-await)
- devBypass.ts: disable detect-possible-timing-attacks on the public well-known
  dev-placeholder string compare (not a secret comparison)
- remove dead code / unused bindings flagged by no-unused-vars: makeTestApp
  (localSession.test), makeUnauthContext + BrowserContext import (login.spec),
  unused memberId (admin.test), unused txSelectCount counter (me.test)
- localAuthMiddleware.test / me.test: fix unused + reflow-detached
  eslint-disable directives

Format: prettier --write across the 20 Phase-19 files that were never formatted.

Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char
TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key
regex hit on "credential atomically, 409-equivalent"). Neither is a real secret.

Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks),
PWA 266/266, API 452/452.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:05:15 -04:00

929 lines
33 KiB
TypeScript

/**
* 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<MeResponse> {
// 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<MeResponse>;
}
// ── /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-<sanitized-uid>-<epochMs>` — 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<OccurrencesResponse> {
const res = await fetch(`/api/events?start=${start}&end=${end}`, {
credentials: 'include',
redirect: 'manual',
});
handleAuthResponse(res, 'GET /api/events');
return res.json() as Promise<OccurrencesResponse>;
}
// 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<CreateEventResponse> {
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<CreateEventResponse>;
}
/**
* 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<CreateEventResponse> {
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<CreateEventResponse>;
}
/**
* 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<void> {
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<SyncStatus> {
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<SyncStatus>;
}
/**
* 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<WritableCalendar[]> {
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<AdminMembersResponse> {
const res = await fetch('/api/admin/members', {
credentials: 'include',
redirect: 'manual',
});
handleAuthResponse(res, 'GET /api/admin/members');
return res.json() as Promise<AdminMembersResponse>;
}
/**
* 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<void> {
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<AdminCalendarsResponse> {
const res = await fetch('/api/admin/calendars', {
credentials: 'include',
redirect: 'manual',
});
handleAuthResponse(res, 'GET /api/admin/calendars');
return res.json() as Promise<AdminCalendarsResponse>;
}
/**
* 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<void> {
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<AdminTimezoneResponse> {
const res = await fetch('/api/admin/config/timezone', {
credentials: 'include',
redirect: 'manual',
});
handleAuthResponse(res, 'GET /api/admin/config/timezone');
return res.json() as Promise<AdminTimezoneResponse>;
}
/**
* 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<void> {
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<void> {
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<SetupStatusResponse> {
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<SetupStatusResponse>;
}
/**
* 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}