feat(notes): isSpaceContext flag replaces kontext module (Option B)

Retire the kontext module entirely; the per-Space standing-context
document is now a regular Note flagged with `isSpaceContext: true`.
Daily use ("URL → Notiz") moves to the notes module as a first-class
action; the same primitive is reused by the (planned) Brand/Firma-Space
onboarding wizard to seed a Space-context Note from a URL.

Why: kontext was inconsistent — its UI was a URL-crawler that wrote
to userContext.freeform (profile module), while its kontextDoc table
+ AI-Mission-Runner auto-injection was a write-only shell with no
real editor. One concept (Notes) now carries both ad-hoc noting and
Space-context, with mutex (max 1 flagged Note per Space).

Notes module:
- types: add `isSpaceContext?: boolean` to LocalNote + Note
- queries: add `useSpaceContextNote()` (the active Space's flagged note)
- store: `markAsSpaceContext(id | null)` with mutex sweep across Space
- ListView: "Aus URL importieren" inline form (URL + crawl-mode +
  KI-Zusammenfassung toggle); "Als Space-Kontext markieren" /
  "Space-Kontext lösen" context-menu item; ★-Badge on flagged notes
- new api.ts: `crawlUrl()` client for POST /api/v1/notes/import-url

Notes API (apps/api):
- new modules/notes/routes.ts with /import-url (ported from kontext;
  same crawler + LLM summary pipeline, NOTES_IMPORT_URL credit op)
- mount at /api/v1/notes; add 'notes' to RESOURCE_MODULES (beta+ tier)
- delete modules/context (UI-less /ai/generate + /ai/estimate had no
  consumers; /import-url moved to notes)
- packages/credits: rename AI_CONTEXT_GENERATION → NOTES_IMPORT_URL

AI Mission Runner:
- default-resolvers: drop kontextResolver + kontextIndexer; the
  notesIndexer flags `isSpaceContext` notes with "★ " prefix and
  bubbles them to the top of the picker
- writing reference-resolver: `kind: 'kontext'` now reads the flagged
  Note via scope-scan instead of the kontextDoc table; tests updated
- writing ReferencePicker: useSpaceContextNote replaces useKontextDoc
- AiDebugBlock + MissionGrantDialog + ai-missions ListView: drop
  'kontextDoc' from ENCRYPTED_SERVER_TABLES set
- ai-agents ListView: drop 'kontext' from POLICY_MODULES

Profile module:
- ContextFreeform.svelte: switch import from kontext/api to notes/api
  (the URL-crawl is the same primitive; it still writes to
  userContext.freeform — only the import path changed)

Dexie:
- v58: notes index gains `isSpaceContext`; kontextDoc table dropped

Kontext module deletion:
- delete apps/mana/apps/web/src/lib/modules/kontext/ entirely
- delete (app)/kontext/ route
- drop registerApp + Scroll icon from app-registry/apps.ts
- drop kontext entry from help-content
- drop kontextModuleConfig from data/module-registry.ts
- drop kontextDoc from crypto registry

mana-auth:
- bootstrap-singletons: drop bootstrapSpaceSingletons function entirely
  (kontextDoc was the only per-Space singleton); userContext bootstrap
  unchanged
- better-auth.config: drop kontextDoc bootstrap call from personal-space
  hook + organizationHooks.afterCreateOrganization
- me-bootstrap: drop per-space bootstrap loop; response shape kept
  (always-empty `spaces: {}`) for backwards-compat with older clients

Note: the still-existing legacy `context` module (CMS-style docs/spaces,
unrelated to kontext) is left in place; its cleanup landed on the
articles-bulk-import branch and is out of scope for this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-29 00:06:34 +02:00
parent 054b9e5beb
commit 8fbdc6db77
37 changed files with 496 additions and 983 deletions

View file

@ -47,7 +47,6 @@ 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.
@ -98,10 +97,10 @@ export interface BetterAuthWebAuthnOptions {
* Create Better Auth instance
*
* @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 syncDatabaseUrl - PostgreSQL connection URL for `mana_sync`. Held
* for use by the per-user `userContext` bootstrap; currently no
* per-Space singletons are written here (the kontextDoc that used to
* live here was retired in the Option-B cleanup).
* @param webauthn - WebAuthn settings for the passkey plugin
* @returns Better Auth instance
*/
@ -272,24 +271,6 @@ export function createBetterAuth(
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),
});
}
);
}
},
},
},
@ -378,32 +359,16 @@ export function createBetterAuth(
/**
* Spaces enforce that every organization carries a valid
* `metadata.type` (the Space type), and block deletion of the
* 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.
* user's personal space. The per-Space `kontextDoc` singleton
* that used to be bootstrapped here was retired in favour of
* the user-driven `notes.isSpaceContext` flag (Option B
* cleanup), so the after-create hook is currently empty
* kept as a hook anchor for future per-Space bootstrap needs.
*/
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

@ -149,10 +149,10 @@ app.route('/api/v1/me/ai-mission-grant', createAiMissionGrantRoutes(missionGrant
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.
// Idempotent reconciliation endpoint for the per-user `userContext`
// singleton. Webapp boot calls this once; the signup-time hook remains
// 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 ──────────────────────────────────────────────

View file

@ -2,34 +2,34 @@
* 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).
* per-user `userContext` singleton. Called once per webapp boot as a
* reconciliation belt-and-suspenders for the signup-time hook
* (databaseHooks.user.create.after).
*
* Why both: the signup hooks are zero-latency happy-path bootstraps but
* Why both: the signup hook is a zero-latency happy-path bootstrap 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
* Idempotency in the bootstrap function 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.
* caller's identity (and thus the userId) comes from the JWT.
*
* Per-Space singletons used to be bootstrapped here too (kontextDoc),
* but the kontextDoc table was retired in favour of the user-driven
* `notes.isSpaceContext` flag there is nothing to bootstrap per
* Space anymore. The response shape keeps the `spaces` map for
* backwards compatibility with older webapp builds; it is always
* empty now.
*/
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';
import { bootstrapUserSingletons } from '../services/bootstrap-singletons';
export interface BootstrapResponse {
ok: true;
@ -40,7 +40,7 @@ export interface BootstrapResponse {
}
export function createMeBootstrapRoutes(
db: Database,
_db: Database,
syncDatabaseUrl: string
): Hono<{ Variables: { user: AuthUser } }> {
// Lazy module-scoped postgres pool. Mirrors routes/auth.ts and
@ -71,38 +71,6 @@ export function createMeBootstrapRoutes(
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

@ -1,28 +1,27 @@
/**
* Server-side singleton bootstrap.
*
* 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.
* On first user-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` 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).
*
* Idempotency: each function performs an existence-check on
* (The per-Space `kontextDoc` singleton was retired the
* notes.isSpaceContext flag now carries the same role, and a flagged
* Note is created on demand by the user, not bootstrapped empty.)
*
* Idempotency: the 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
* - signup-time hook (databaseHooks.user.create.after) fires on the
* happy path
* - boot-time endpoint (POST /api/v1/me/bootstrap-singletons) fires
* on every webapp boot as a reconciliation belt-and-suspenders
*
@ -88,19 +87,6 @@ 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. Idempotent
* skips the insert if a row for `(userContext, 'singleton', userId)`
@ -149,62 +135,3 @@ export async function bootstrapUserSingletons(
`;
return true;
}
/**
* 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. 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<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);
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}
)
`;
return true;
}