feat(auth): explicit bootstrap-singletons endpoint + idempotent functions (F4 robust)

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-27 01:38:14 +02:00
parent 98d334045a
commit 099cac4a01
7 changed files with 261 additions and 24 deletions

View file

@ -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));

View file

@ -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<string, boolean>;
};
}
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<typeof postgres> | null = null;
const getSyncSql = (): ReturnType<typeof postgres> => {
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);
});
}

View file

@ -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<string, unknown> {
}
/**
* 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<typeof postgres>
): Promise<void> {
): Promise<boolean> {
if (!userId) throw new Error('bootstrapUserSingletons: empty userId');
const existing = await syncSql<Array<{ exists: number }>>`
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<typeof postgres>
): Promise<void> {
): Promise<boolean> {
if (!spaceId) throw new Error('bootstrapSpaceSingletons: empty spaceId');
if (!ownerUserId) throw new Error('bootstrapSpaceSingletons: empty ownerUserId');
const existing = await syncSql<Array<{ exists: number }>>`
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;
}