feat(auth): bootstrap per-Space kontextDoc on Space-creation (F4 follow-up)

Symmetrically extends the F4 server-side singleton bootstrap to the
per-Space `kontextDoc`. Every Space-creation — Personal at signup and
brand/club/family/team/practice via the org plugin — now writes an empty
kontextDoc row straight into mana_sync.sync_changes with origin='system',
client_id='system:bootstrap'. Fresh clients pull the row instead of
racing on a local insert that the next pull would clobber.

- New `bootstrapSpaceSingletons(spaceId, ownerUserId, syncSql)` in
  services/mana-auth/src/services/bootstrap-singletons.ts; shared
  `buildFieldMeta` helper extracted.
- `createBetterAuth(databaseUrl, syncDatabaseUrl, webauthn)` now takes
  the sync-DB URL and lazy-creates a module-scoped postgres pool for
  the bootstrap inserts.
- Hook into `databaseHooks.user.create.after` (only on `created: true`
  from createPersonalSpaceFor) and `organizationHooks.afterCreateOrganization`.
- Webapp `kontextStore.ensureDoc()` made private as `getOrCreateLocalDoc()` —
  same fallback role as userContextStore's after F5. Public API is now just
  setContent + appendContent.

Plan: docs/plans/sync-field-meta-overhaul.md (F4-fu row in Shipping Log).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-27 01:21:31 +02:00
parent bcf150ea16
commit 3df7391905
8 changed files with 197 additions and 66 deletions

View file

@ -21,6 +21,8 @@ import { organization } from 'better-auth/plugins/organization';
import { twoFactor } from 'better-auth/plugins/two-factor';
import { magicLink } from 'better-auth/plugins/magic-link';
import { passkey } from '@better-auth/passkey';
import postgres from 'postgres';
import { logger } from '@mana/shared-hono';
import { getDb } from '../db/connection';
import { organizations, members, invitations } from '../db/schema/organizations';
import {
@ -45,6 +47,7 @@ import {
assertSpaceIsDeletable,
createPersonalSpaceFor,
} from '../spaces';
import { bootstrapSpaceSingletons } from '../services/bootstrap-singletons';
// Re-export so existing imports (`import { TRUSTED_ORIGINS } from './better-auth.config'`)
// keep working. New code should import from './sso-origins' directly.
@ -94,13 +97,30 @@ export interface BetterAuthWebAuthnOptions {
/**
* Create Better Auth instance
*
* @param databaseUrl - PostgreSQL connection URL
* @param databaseUrl - PostgreSQL connection URL for the auth DB
* @param syncDatabaseUrl - PostgreSQL connection URL for `mana_sync`. The
* personal-space + organization hooks bootstrap per-Space singletons
* into `sync_changes` so fresh clients pull the row instead of racing
* on a local insert. See `bootstrapSpaceSingletons`.
* @param webauthn - WebAuthn settings for the passkey plugin
* @returns Better Auth instance
*/
export function createBetterAuth(databaseUrl: string, webauthn: BetterAuthWebAuthnOptions) {
export function createBetterAuth(
databaseUrl: string,
syncDatabaseUrl: string,
webauthn: BetterAuthWebAuthnOptions
) {
const db = getDb(databaseUrl);
// Lazy module-scoped sync SQL pool. Mirrors the pattern in
// routes/auth.ts so we don't open a second pool just for the
// org-create hook. 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 betterAuth({
// Database adapter (Drizzle with PostgreSQL)
database: drizzleAdapter(db, {
@ -246,12 +266,30 @@ export function createBetterAuth(databaseUrl: string, webauthn: BetterAuthWebAut
user: {
create: {
after: async (user) => {
await createPersonalSpaceFor(db, {
const result = await createPersonalSpaceFor(db, {
id: user.id,
email: user.email,
name: user.name,
accessTier: (user as { accessTier?: string | null }).accessTier,
});
// Bootstrap the personal Space's kontextDoc only on a
// real first-time creation. The `created: false` path
// means a previous signup retry already provisioned it
// and the doc has been bootstrapped before. Failures
// are logged but do not abort signup — the webapp's
// `ensureDoc()` fallback still creates the row on the
// first write attempt.
if (result.created) {
bootstrapSpaceSingletons(result.organizationId, user.id, getSyncSql()).catch(
(err: unknown) => {
logger.error('[auth] bootstrapSpaceSingletons (personal) failed', {
userId: user.id,
organizationId: result.organizationId,
err: err instanceof Error ? err.message : String(err),
});
}
);
}
},
},
},
@ -340,13 +378,32 @@ export function createBetterAuth(databaseUrl: string, webauthn: BetterAuthWebAut
/**
* Spaces enforce that every organization carries a valid
* `metadata.type` (the Space type), and block deletion of the
* user's personal space. See docs/plans/spaces-foundation.md
* user's personal space. After-create bootstraps per-Space
* singletons (currently `kontextDoc`) into mana_sync so fresh
* clients pull the row instead of racing on a local insert.
* Personal-space gets the same bootstrap, but from
* `databaseHooks.user.create.after` because Better Auth's
* `afterCreateOrganization` does not fire on the implicit
* personal-space creation that runs inside the user-create
* hook (createPersonalSpaceFor writes to `organizations`
* directly via Drizzle). See docs/plans/spaces-foundation.md
* and ../spaces/metadata.ts.
*/
organizationHooks: {
beforeCreateOrganization: async ({ organization }) => {
assertValidSpaceMetadataForCreate(organization.metadata);
},
afterCreateOrganization: async ({ organization, user }) => {
bootstrapSpaceSingletons(organization.id, user.id, getSyncSql()).catch(
(err: unknown) => {
logger.error('[auth] bootstrapSpaceSingletons (org-hook) failed', {
userId: user.id,
organizationId: organization.id,
err: err instanceof Error ? err.message : String(err),
});
}
);
},
beforeDeleteOrganization: async ({ organization }) => {
assertSpaceIsDeletable(organization.metadata);
},

View file

@ -44,7 +44,7 @@ import { createInternalPersonasRoutes } from './routes/internal-personas';
initLogger('mana-auth');
const config = loadConfig();
const db = getDb(config.databaseUrl);
const auth = createBetterAuth(config.databaseUrl, config.webauthn);
const auth = createBetterAuth(config.databaseUrl, config.syncDatabaseUrl, config.webauthn);
// Load the Key Encryption Key before any vault operation can run.
// Top-level await is supported by Bun. Throws if MANA_AUTH_KEK is

View file

@ -1,34 +1,33 @@
/**
* Server-side singleton bootstrap.
*
* On first user-creation, write the per-user singleton records that the
* webapp would otherwise create on demand via `ensureDoc()`. This makes
* the bootstrap deterministic every fresh client pulls the singleton
* from mana-sync instead of racing on a local insert.
* On first user-creation and Space-creation, write the singleton records
* that the webapp would otherwise create on demand via `ensureDoc()` /
* `getOrCreateLocalDoc()`. This makes the bootstrap deterministic every
* fresh client pulls the singleton from mana-sync instead of racing on a
* local insert that the next pull would clobber.
*
* Currently bootstrapped:
* - `userContext` the structured profile + freeform markdown blob
* keyed by `id='singleton'`. Default shape mirrors the webapp's
* - `userContext` per-user. The structured profile + freeform markdown
* blob keyed by `id='singleton'`. Default shape mirrors the webapp's
* `emptyUserContext()` factory in `profile/types.ts`.
* - `kontextDoc` per-Space. The freeform markdown context document
* (encrypted at rest in normal client writes; bootstrap writes the
* empty default in plaintext, the client's `decryptRecord` skips
* non-envelope strings so this is safe).
*
* Not bootstrapped here:
* - `kontextDoc` per-Space, not per-user. Created on Space creation
* by the Spaces foundation; bootstrap there if needed in F4
* follow-up. The webapp's `ensureDoc()` for kontextDoc is still
* race-anfällig but doesn't surface the symptom F4 closes.
*
* 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 user-creation success calling this twice for the same
* user will produce two insert rows for the same singleton, and the
* field-LWW replay on the client will collapse them into one record
* with the latest field-meta winning. Harmless, but wasteful, so the
* post-signUp hook in routes/auth.ts only fires it once per real
* registration.
* 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.
*/
import type postgres from 'postgres';
import postgres from 'postgres';
interface Actor {
kind: 'system';
@ -45,6 +44,21 @@ const BOOTSTRAP_ACTOR: Actor = {
const BOOTSTRAP_CLIENT_ID = 'system:bootstrap';
const BOOTSTRAP_ORIGIN = 'system';
/**
* Build a `field_meta` object for the bootstrap insert: every key in
* `data` (except `id`) gets the same `at` timestamp. The receiving client
* reads this column and populates `__fieldMeta[k] = { at, actor:
* changeActor, origin: 'server-replay' }` — never surfaces as a conflict.
*/
function buildFieldMeta(data: Record<string, unknown>, at: string): Record<string, string> {
const meta: Record<string, string> = {};
for (const key of Object.keys(data)) {
if (key === 'id') continue;
meta[key] = at;
}
return meta;
}
/**
* Default content for a new user's `userContext` singleton. Keep in sync
* with `apps/mana/apps/web/src/lib/modules/profile/types.ts:emptyUserContext()`.
@ -69,11 +83,25 @@ function emptyUserContextData(userId: string): Record<string, unknown> {
};
}
/**
* Default content for a new Space's `kontextDoc`. Just an id + empty
* content the user fills in the markdown later. Encryption is skipped
* (empty string reveals nothing); the client's `decryptRecord` is
* tolerant of plaintext values for encrypted-registry fields.
*/
function emptyKontextDocData(): Record<string, unknown> {
return {
id: crypto.randomUUID(),
content: '',
};
}
/**
* 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 `ensureDoc()`
* is still in place as a fallback for the F4-F5 transition window).
* 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).
*/
export async function bootstrapUserSingletons(
userId: string,
@ -83,15 +111,7 @@ export async function bootstrapUserSingletons(
const now = new Date().toISOString();
const data = emptyUserContextData(userId);
// Per-field at stamp for every real data field. The receiving client
// reads `field_meta` to populate `__fieldMeta[k] = { at, actor:
// changeActor, origin: 'server-replay' }` — no conflicts, no toasts.
const fieldMeta: Record<string, string> = {};
for (const key of Object.keys(data)) {
if (key === 'id') continue;
fieldMeta[key] = now;
}
const fieldMeta = buildFieldMeta(data, now);
await syncSql`
INSERT INTO sync_changes (
@ -108,3 +128,43 @@ export async function bootstrapUserSingletons(
)
`;
}
/**
* 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.
*
* `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.
*/
export async function bootstrapSpaceSingletons(
spaceId: string,
ownerUserId: string,
syncSql: ReturnType<typeof postgres>
): Promise<void> {
if (!spaceId) throw new Error('bootstrapSpaceSingletons: empty spaceId');
if (!ownerUserId) throw new Error('bootstrapSpaceSingletons: empty ownerUserId');
const now = new Date().toISOString();
const data = emptyKontextDocData();
const fieldMeta = buildFieldMeta(data, now);
await syncSql`
INSERT INTO sync_changes (
app_id, table_name, record_id, user_id, space_id, op, data,
field_meta, client_id, schema_version, actor, origin
)
VALUES (
'kontext', 'kontextDoc', ${data.id as string}, ${ownerUserId}, ${spaceId}, 'insert',
${syncSql.json(data as never)},
${syncSql.json(fieldMeta as never)},
${BOOTSTRAP_CLIENT_ID}, 1,
${syncSql.json(BOOTSTRAP_ACTOR as never)},
${BOOTSTRAP_ORIGIN}
)
`;
}