From 099cac4a0151dabad969a1ec1562aae9148c5a5f Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 27 Apr 2026 01:38:14 +0200 Subject: [PATCH] feat(auth): explicit bootstrap-singletons endpoint + idempotent functions (F4 robust) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The F4 server-side singleton bootstrap was fire-and-forget at signup time — a transient mana_sync outage during registration would leave the user with no singleton and only the in-store `getOrCreateLocalDoc()` fallback to race on the first write. The signup-hook is still the happy-path zero-latency bootstrap; this commit adds a deliberate reconciliation path that converges on every boot. - Idempotent `bootstrapUserSingletons` / `bootstrapSpaceSingletons`: both functions now existence-check sync_changes before INSERT and return boolean (true=inserted, false=skipped). - New endpoint `POST /api/v1/me/bootstrap-singletons` — JWT-gated under the existing `/api/v1/me/*` prefix. Provisions the caller's userContext and the kontextDoc for every Space they're a member of. Returns `{ ok, bootstrapped: { userContext, spaces: { id: bool } } }`. - Webapp `(app)/+layout.svelte` calls the endpoint once per authenticated boot, after `restoreClientIdFromDexie()` and before `createUnifiedSync.startAll()`. Best-effort; failures swallow into a console warning and the in-store fallback still covers the rare race window. Plan: docs/plans/sync-field-meta-overhaul.md (F4-robust row). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../apps/web/src/lib/data/DATA_LAYER_AUDIT.md | 1 + .../web/src/lib/data/bootstrap-singletons.ts | 72 ++++++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 7 ++ docs/plans/sync-field-meta-overhaul.md | 3 +- services/mana-auth/src/index.ts | 8 ++ services/mana-auth/src/routes/me-bootstrap.ts | 108 ++++++++++++++++++ .../src/services/bootstrap-singletons.ts | 86 ++++++++++---- 7 files changed, 261 insertions(+), 24 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/data/bootstrap-singletons.ts create mode 100644 services/mana-auth/src/routes/me-bootstrap.ts diff --git a/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md b/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md index 7273c3f8e..e2d8c39aa 100644 --- a/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md +++ b/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md @@ -206,6 +206,7 @@ Sieben Phasen, die vier strukturelle Bugs in der Conflict-Detection abgeräumt h - **F6** (`a031493fe`) Stable `client_id` in Dexie-Tabelle `_clientIdentity`. `restoreClientIdFromDexie()` läuft im (app)-Layout vor `createUnifiedSync` und reconciliated Dexie ↔ localStorage. Dexie ist canonical, localStorage ist fast-read-cache. Survives clear-site-data und incognito flush. - **F7** (`2a8e8ff98`) `repair-silent-twin.ts` + `legacy-avatar.ts` Migrationen ersatzlos gelöscht — pre-live, keine Live-Daten brauchen sie. Orphan-localStorage-Flags-Sweep im Boot (`migrations-cleanup.ts`, `119cd2cf8`) räumt die zugehörigen Flags auf. - **F3-fu (v55 cleanup)** (_pending_) Dexie v55 row-rewrite: löscht den Orphan-`updatedAt`-Wert aus jedem Row in `Object.keys(TABLE_TO_APP)`. v53 hatte ihn bewusst stehengelassen (Comment "next-version upgrade can drop it"); nach F3+F5 liest niemand mehr `row.updatedAt`, also pure waste. Idempotent — rows ohne das Feld sind ein no-op. +- **F4-robust (Endpoint)** (_pending_) Bootstrap-Funktionen idempotent (existence-check) + expliziter Endpoint `POST /api/v1/me/bootstrap-singletons`. Webapp callt ihn auf Boot vor `createUnifiedSync`. Signup-Hooks bleiben als happy-path; Endpoint ist Reconciliation belt-and-suspenders, sodass eine transient mana_sync-Outage beim Signup nicht den User mit `getOrCreateLocalDoc()`-Race auf erstem Write strandet. Die vier Bug-Wurzeln (siehe ursprüngliche Diagnose 2026-04-26): diff --git a/apps/mana/apps/web/src/lib/data/bootstrap-singletons.ts b/apps/mana/apps/web/src/lib/data/bootstrap-singletons.ts new file mode 100644 index 000000000..eb501a94f --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/bootstrap-singletons.ts @@ -0,0 +1,72 @@ +/** + * Boot-time singleton bootstrap reconciliation. + * + * Calls `POST /api/v1/me/bootstrap-singletons` on every authenticated + * boot. The server-side endpoint provisions any missing per-user + * (`userContext`) and per-Space (`kontextDoc`) singletons in + * `mana_sync.sync_changes`. Idempotent — a second call is a no-op. + * + * Why call it on boot when the signup-time hooks already do this work: + * the hooks are fire-and-forget and a transient mana_sync outage during + * registration can leave the user with no singleton row. The boot-time + * endpoint converges to the right state on every load, so a one-time + * outage doesn't strand the user with `getOrCreateLocalDoc()` racing on + * the first write. + * + * Best-effort: failures are swallowed and logged. The webapp's + * fallback paths (`getOrCreateLocalDoc()` in `userContextStore` / + * `kontextStore`) still cover the rare race where a write happens + * before the bootstrap row arrives. + */ + +import { browser } from '$app/environment'; +import { authStore } from '$lib/stores/auth.svelte'; + +function getAuthUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_MANA_AUTH_URL__?: string }) + .__PUBLIC_MANA_AUTH_URL__; + if (injected) return injected; + } + return import.meta.env.DEV ? 'http://localhost:3001' : ''; +} + +interface BootstrapResponse { + ok: true; + bootstrapped: { + userContext: boolean; + spaces: Record; + }; +} + +export async function bootstrapSingletons(): Promise { + if (!browser) return; + const token = await authStore.getValidToken(); + if (!token) return; + try { + const res = await fetch(`${getAuthUrl()}/api/v1/me/bootstrap-singletons`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + if (!res.ok) { + console.warn('[bootstrap-singletons] endpoint returned', res.status); + return; + } + const body = (await res.json()) as BootstrapResponse; + if (import.meta.env.DEV) { + const newUser = body.bootstrapped.userContext; + const newSpaces = Object.values(body.bootstrapped.spaces).filter(Boolean).length; + if (newUser || newSpaces > 0) { + console.info( + `[bootstrap-singletons] reconciled — userContext=${newUser ? 'inserted' : 'present'}, ` + + `spaces inserted=${newSpaces}/${Object.keys(body.bootstrapped.spaces).length}` + ); + } + } + } catch (err) { + console.warn('[bootstrap-singletons] call failed:', err); + } +} diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 961b0013d..dfa19ab5a 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -71,6 +71,7 @@ } from '$lib/modules/memoro/llm-watcher.svelte'; import { createUnifiedSync, restoreClientIdFromDexie } from '$lib/data/sync'; import { cleanupOrphanMigrationFlags } from '$lib/data/migrations-cleanup'; + import { bootstrapSingletons } from '$lib/data/bootstrap-singletons'; import { syncBilling } from '$lib/stores/sync-billing.svelte'; import { networkStore } from '$lib/stores/network.svelte'; import { db } from '$lib/data/database'; @@ -636,6 +637,12 @@ // Sweep stale localStorage flags from migration helpers that // have since been deleted (F7 + future cleanups). cleanupOrphanMigrationFlags(); + // Reconcile per-user + per-Space singletons via mana-auth's + // idempotent bootstrap endpoint before the sync engine starts + // pulling. Best-effort — failures fall back to the in-store + // `getOrCreateLocalDoc()` path that handles the rare race + // where a write happens before the bootstrap row arrives. + void bootstrapSingletons(); const getToken = () => authStore.getValidToken(); unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken, syncBilling.active); // Expose on window for SYNC_DEBUG.md (Schritt C). Not a security diff --git a/docs/plans/sync-field-meta-overhaul.md b/docs/plans/sync-field-meta-overhaul.md index 2d74a71d2..0498a9c1b 100644 --- a/docs/plans/sync-field-meta-overhaul.md +++ b/docs/plans/sync-field-meta-overhaul.md @@ -153,7 +153,8 @@ _Wird befüllt während der Ausführung._ | F6 | `a031493fe` | Stable `client_id` in Dexie. Neue Tabelle `_clientIdentity` (single row keyed by `id='self'`). `restoreClientIdFromDexie()` läuft einmal beim Boot in `+layout.svelte` vor `createUnifiedSync` und reconciliated Dexie ↔ localStorage: Dexie ist canonical, localStorage ist fast-read cache. Ein localStorage-Wipe wird beim nächsten Boot aus Dexie restored. Dexie v54 mit `_clientIdentity: 'id'`. Survives clear-site-data, incognito flush. | | F7 | `2a8e8ff98` | `repair-silent-twin.ts` + `legacy-avatar.ts` Migrationen ersatzlos gelöscht. Beide existierten nur um die Symptome eines fixed-in-M2.5 Bugs zu cleanen, der pre-live keine echten Daten produziert hat. Mit F2's `origin='migration'` wrapper + F3's drop von synced `updatedAt` würden ihre writes auch nicht mehr als Conflicts auftauchen — sie waren strukturell überflüssig. Caller in MeImagesView + wardrobe/ListView entfernt; leere `migration/` Directory gelöscht. | | F4-fu (kontextDoc) | `3df739190` | F4-Symmetrie: `bootstrapSpaceSingletons(spaceId, ownerUserId, syncSql)` in `bootstrap-singletons.ts`, schreibt einen leeren `kontext/kontextDoc` row pro Space-Erstellung in `mana_sync.sync_changes` mit `client_id='system:bootstrap'`, `origin='system'`. Zwei Aufruf-Sites: `databaseHooks.user.create.after` (Personal-Space; nur wenn `createPersonalSpaceFor` `created: true` zurückgibt) + `organizationHooks.afterCreateOrganization` (alle non-personal Spaces). `createBetterAuth` kriegt `syncDatabaseUrl` als zweites Argument; lazy module-scoped postgres-pool. Webapp `kontextStore.ensureDoc()` zu privat `getOrCreateLocalDoc()` umbenannt — Public-API ist nur noch `setContent` + `appendContent`. Kontext content bleibt encrypted at rest auf dem Client (`kontextDoc.content`); der Server-Bootstrap schreibt `''` plaintext, was im Client-`decryptRecord` toleriert wird (non-envelope strings werden durchgereicht). | -| F3-fu (v55 cleanup) | _pending_ | Dexie v55 löscht den Orphan-`updatedAt`-Wert aus jedem Row in `Object.keys(TABLE_TO_APP)`. v53 hatte ihn bewusst stehengelassen (Comment "next-version upgrade can drop it"); nach F3 liest niemand mehr `row.updatedAt` — `deriveUpdatedAt(local)` aus `__fieldMeta` ist die SSOT. Idempotent (delete missing field = no-op), best-effort (try/catch pro Tabelle für unbekannte/missing stores). | +| F3-fu (v55 cleanup) | `53fecbf4a` | Dexie v55 löscht den Orphan-`updatedAt`-Wert aus jedem Row in `Object.keys(TABLE_TO_APP)`. v53 hatte ihn bewusst stehengelassen (Comment "next-version upgrade can drop it"); nach F3 liest niemand mehr `row.updatedAt` — `deriveUpdatedAt(local)` aus `__fieldMeta` ist die SSOT. Idempotent (delete missing field = no-op), best-effort (try/catch pro Tabelle für unbekannte/missing stores). | +| F4-robust (Endpoint) | _pending_ | F4-Bootstrap robuster gemacht via expliziten Endpoint `POST /api/v1/me/bootstrap-singletons` in mana-auth. Beide Bootstrap-Funktionen (`bootstrapUserSingletons`, `bootstrapSpaceSingletons`) sind jetzt idempotent (existence-check vor INSERT) und geben `boolean` zurück. Endpoint ruft beide auf — userContext für den Caller, kontextDoc für jeden Space, in dem der Caller Member ist. Webapp `(app)/+layout.svelte` callt den Endpoint einmal pro Boot vor `createUnifiedSync`, fire-and-forget am Client. Signup-Hooks (`databaseHooks.user.create.after`, `organizationHooks.afterCreateOrganization`) bleiben als happy-path; Endpoint ist Reconciliation belt-and-suspenders. | ## F1 — Implementation Notes diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts index 026328d5f..9ee20f5b1 100644 --- a/services/mana-auth/src/index.ts +++ b/services/mana-auth/src/index.ts @@ -31,6 +31,7 @@ import { createPasskeyRoutes } from './routes/passkeys'; import { createGuildRoutes } from './routes/guilds'; import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys'; import { createMeRoutes } from './routes/me'; +import { createMeBootstrapRoutes } from './routes/me-bootstrap'; import { createOnboardingRoutes } from './routes/onboarding'; import { createEncryptionVaultRoutes } from './routes/encryption-vault'; import { createAiMissionGrantRoutes } from './routes/ai-mission-grant'; @@ -138,6 +139,13 @@ app.route('/api/v1/me/ai-mission-grant', createAiMissionGrantRoutes(missionGrant // See docs/plans/onboarding-flow.md. app.route('/api/v1/me/onboarding', createOnboardingRoutes(db)); +// ─── Singleton Bootstrap ──────────────────────────────────── +// Idempotent reconciliation endpoint for per-user + per-Space sync +// singletons (userContext, kontextDoc). Webapp boot calls this once; +// signup-time hooks remain the happy path. See +// docs/plans/sync-field-meta-overhaul.md and routes/me-bootstrap.ts. +app.route('/api/v1/me/bootstrap-singletons', createMeBootstrapRoutes(db, config.syncDatabaseUrl)); + // ─── Settings ────────────────────────────────────────────── app.use('/api/v1/settings/*', jwtAuth(config.baseUrl)); diff --git a/services/mana-auth/src/routes/me-bootstrap.ts b/services/mana-auth/src/routes/me-bootstrap.ts new file mode 100644 index 000000000..4acad015c --- /dev/null +++ b/services/mana-auth/src/routes/me-bootstrap.ts @@ -0,0 +1,108 @@ +/** + * Singleton bootstrap endpoint. + * + * `POST /api/v1/me/bootstrap-singletons` — idempotently provisions the + * per-user `userContext` singleton and the per-Space `kontextDoc` for + * every Space the caller is a member of. Called once per webapp boot + * as a reconciliation belt-and-suspenders for the signup-time hooks + * (databaseHooks.user.create.after + organizationHooks.afterCreateOrganization). + * + * Why both: the signup hooks are zero-latency happy-path bootstraps but + * fire-and-forget — a transient mana_sync outage during signup leaves + * the user with no singleton and no signal that anything is wrong. The + * boot-time endpoint converges to the right state on every load. + * Idempotency in the bootstrap functions makes double-invocation + * harmless. + * + * The endpoint is deliberately simple: no body, no parameters. The + * caller's identity (and thus the userId + space-membership list) + * comes from the JWT. + */ + +import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; +import postgres from 'postgres'; +import { logger } from '@mana/shared-hono'; +import type { AuthUser } from '../middleware/jwt-auth'; +import type { Database } from '../db/connection'; +import { members } from '../db/schema/organizations'; +import { + bootstrapUserSingletons, + bootstrapSpaceSingletons, +} from '../services/bootstrap-singletons'; + +export interface BootstrapResponse { + ok: true; + bootstrapped: { + userContext: boolean; + spaces: Record; + }; +} + +export function createMeBootstrapRoutes( + db: Database, + syncDatabaseUrl: string +): Hono<{ Variables: { user: AuthUser } }> { + // Lazy module-scoped postgres pool. Mirrors routes/auth.ts and + // better-auth.config.ts — process lifetime owns it; never closed + // manually. + let _syncSql: ReturnType | null = null; + const getSyncSql = (): ReturnType => { + if (!_syncSql) _syncSql = postgres(syncDatabaseUrl, { max: 2 }); + return _syncSql; + }; + + return new Hono<{ Variables: { user: AuthUser } }>().post('/', async (c) => { + const user = c.get('user'); + const syncSql = getSyncSql(); + + const result: BootstrapResponse = { + ok: true, + bootstrapped: { userContext: false, spaces: {} }, + }; + + try { + result.bootstrapped.userContext = await bootstrapUserSingletons(user.userId, syncSql); + } catch (err) { + logger.error('[me/bootstrap-singletons] userContext failed', { + userId: user.userId, + err: err instanceof Error ? err.message : String(err), + }); + return c.json({ ok: false, error: 'userContext bootstrap failed' }, 500); + } + + // Bootstrap every Space the user is a member of. The owner of a + // Space is the canonical writer for its singletons, but RLS + // only gates by user_id (writer); the membership-aware pull + // delivers the row to every member regardless of which member + // inserted it. If the owner's bootstrap failed at signup time + // and a non-owner member calls this endpoint first, the + // member's bootstrap stands in. + const memberRows = await db + .select({ organizationId: members.organizationId }) + .from(members) + .where(eq(members.userId, user.userId)); + + for (const row of memberRows) { + const spaceId = row.organizationId; + try { + result.bootstrapped.spaces[spaceId] = await bootstrapSpaceSingletons( + spaceId, + user.userId, + syncSql + ); + } catch (err) { + logger.error('[me/bootstrap-singletons] space failed', { + userId: user.userId, + spaceId, + err: err instanceof Error ? err.message : String(err), + }); + // Don't abort — surface the per-space outcome and + // continue. The caller can retry on next boot. + result.bootstrapped.spaces[spaceId] = false; + } + } + + return c.json(result); + }); +} diff --git a/services/mana-auth/src/services/bootstrap-singletons.ts b/services/mana-auth/src/services/bootstrap-singletons.ts index 5420a6c4d..d53f36bfd 100644 --- a/services/mana-auth/src/services/bootstrap-singletons.ts +++ b/services/mana-auth/src/services/bootstrap-singletons.ts @@ -16,15 +16,20 @@ * empty default in plaintext, the client's `decryptRecord` skips * non-envelope strings so this is safe). * - * Idempotency: `ON CONFLICT (...) DO NOTHING` on the sync_changes primary - * key would only catch re-inserts of the same row id, which never happens - * (UUIDs are fresh per call). Instead the caller MUST gate on the first - * real creation event — the user-create hook fires once per real signup, - * and the personal-space + organization hooks fire once per real Space - * creation. Calling either bootstrap twice for the same target produces - * two insert rows; field-LWW replay collapses them on the client (latest - * `at` wins). Harmless but wasteful, hence the `created: true` gate in - * `createPersonalSpaceFor`'s caller. + * Idempotency: each function performs an existence-check on + * `sync_changes` before inserting — if a row matching the singleton's + * scope already exists, the call is a no-op. This makes the bootstrap + * safe to run from multiple sources without producing duplicate rows: + * - signup-time hooks (databaseHooks.user.create.after, + * organizationHooks.afterCreateOrganization) — fire on the happy + * path + * - boot-time endpoint (POST /api/v1/me/bootstrap-singletons) — fires + * on every webapp boot as a reconciliation belt-and-suspenders + * + * The TOCTOU race between two concurrent callers can theoretically + * still produce a duplicate insert, but field-LWW collapses duplicates + * harmlessly on the client (latest `at` wins). The check is a + * waste-reduction, not a correctness mechanism. */ import postgres from 'postgres'; @@ -97,18 +102,33 @@ function emptyKontextDocData(): Record { } /** - * Insert the per-user singletons into mana_sync.sync_changes. Called - * fire-and-forget from the post-signUp hook in routes/auth.ts; failures - * are logged but do not abort registration (the webapp's - * `getOrCreateLocalDoc()` is still in place as a fallback for the rare - * race where the first pull hasn't landed yet). + * Insert the per-user singletons into mana_sync.sync_changes. Idempotent + * — skips the insert if a row for `(userContext, 'singleton', userId)` + * already exists. Called from the post-signUp hook in routes/auth.ts and + * from the boot-time `/me/bootstrap-singletons` endpoint; both are + * fire-and-forget at the caller, but the caller can also `await` it + * (the boot endpoint does) and report failure to the client without + * causing a write conflict. + * + * Returns true if an insert was actually written, false if the + * idempotency check skipped it. */ export async function bootstrapUserSingletons( userId: string, syncSql: ReturnType -): Promise { +): Promise { if (!userId) throw new Error('bootstrapUserSingletons: empty userId'); + const existing = await syncSql>` + SELECT 1 AS exists + FROM sync_changes + WHERE table_name = 'userContext' + AND record_id = 'singleton' + AND user_id = ${userId} + LIMIT 1 + `; + if (existing.length > 0) return false; + const now = new Date().toISOString(); const data = emptyUserContextData(userId); const fieldMeta = buildFieldMeta(data, now); @@ -127,28 +147,47 @@ export async function bootstrapUserSingletons( ${BOOTSTRAP_ORIGIN} ) `; + return true; } /** - * Insert the per-Space singletons into mana_sync.sync_changes. Called - * after every real Space creation: - * - Personal Space — from `databaseHooks.user.create.after` once - * `createPersonalSpaceFor` returns `created: true`. - * - Brand / club / family / team / practice — from - * `organizationHooks.afterCreateOrganization` on the org plugin. + * Insert the per-Space singletons into mana_sync.sync_changes. Idempotent + * — skips the insert if any `kontextDoc` row already exists for the + * given `spaceId` (regardless of writer). Called from: + * - `databaseHooks.user.create.after` once `createPersonalSpaceFor` + * returns `created: true` (personal-space happy path) + * - `organizationHooks.afterCreateOrganization` (brand / club / + * family / team / practice happy path) + * - `POST /api/v1/me/bootstrap-singletons` for every space the + * caller is a member of (boot-time reconciliation) * * `ownerUserId` is the writer (RLS guard); `spaceId` is the data scope. * For non-personal Spaces the inviting user remains the writer — joining - * members will receive the row via the membership-aware pull. + * members will receive the row via the membership-aware pull. If the + * inviter's bootstrap somehow failed and a member triggers it later via + * the endpoint, the member becomes the writer; the row is still + * delivered to all members via the membership-aware pull. + * + * Returns true if an insert was actually written, false if the + * idempotency check skipped it. */ export async function bootstrapSpaceSingletons( spaceId: string, ownerUserId: string, syncSql: ReturnType -): Promise { +): Promise { if (!spaceId) throw new Error('bootstrapSpaceSingletons: empty spaceId'); if (!ownerUserId) throw new Error('bootstrapSpaceSingletons: empty ownerUserId'); + const existing = await syncSql>` + SELECT 1 AS exists + FROM sync_changes + WHERE table_name = 'kontextDoc' + AND space_id = ${spaceId} + LIMIT 1 + `; + if (existing.length > 0) return false; + const now = new Date().toISOString(); const data = emptyKontextDocData(); const fieldMeta = buildFieldMeta(data, now); @@ -167,4 +206,5 @@ export async function bootstrapSpaceSingletons( ${BOOTSTRAP_ORIGIN} ) `; + return true; }