@cosmicdrift/kumiko-bundled-features 0.147.1 → 0.147.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +12 -7
  2. package/src/auth-email-password/feature.ts +6 -1
  3. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  4. package/src/auth-email-password/handlers/login.write.ts +66 -9
  5. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +15 -5
  6. package/src/auth-email-password/web/__tests__/test-utils.tsx +12 -2
  7. package/src/auth-email-password/web/auth-client.ts +34 -12
  8. package/src/auth-email-password/web/auth-gate.tsx +32 -3
  9. package/src/auth-email-password/web/client-plugin.ts +11 -2
  10. package/src/auth-email-password/web/index.ts +4 -2
  11. package/src/auth-email-password/web/login-screen.tsx +31 -2
  12. package/src/auth-email-password/web/session.tsx +11 -6
  13. package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +202 -0
  14. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +150 -0
  15. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +167 -0
  16. package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +168 -0
  17. package/src/auth-mfa/__tests__/verify.integration.test.ts +186 -0
  18. package/src/auth-mfa/config.ts +26 -0
  19. package/src/auth-mfa/constants.ts +25 -0
  20. package/src/auth-mfa/db/queries.ts +34 -0
  21. package/src/auth-mfa/errors.ts +73 -0
  22. package/src/auth-mfa/feature.ts +146 -0
  23. package/src/auth-mfa/handlers/disable.write.ts +51 -0
  24. package/src/auth-mfa/handlers/enable-confirm.write.ts +68 -0
  25. package/src/auth-mfa/handlers/enable-start.write.ts +66 -0
  26. package/src/auth-mfa/handlers/reencrypt.job.ts +185 -0
  27. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +62 -0
  28. package/src/auth-mfa/handlers/verify.write.ts +141 -0
  29. package/src/auth-mfa/i18n.ts +12 -0
  30. package/src/auth-mfa/index.ts +15 -0
  31. package/src/auth-mfa/mfa-challenge-token.ts +90 -0
  32. package/src/auth-mfa/mfa-setup-token.ts +90 -0
  33. package/src/auth-mfa/mfa-status-checker.ts +75 -0
  34. package/src/auth-mfa/mfa-verify-attempts.ts +90 -0
  35. package/src/auth-mfa/recovery-codes.ts +44 -0
  36. package/src/auth-mfa/schema/user-mfa.ts +40 -0
  37. package/src/auth-mfa/totp.ts +8 -0
  38. package/src/auth-mfa/verify-factor.ts +30 -0
  39. package/src/auth-mfa/web/client-plugin.ts +37 -0
  40. package/src/auth-mfa/web/i18n.ts +69 -0
  41. package/src/auth-mfa/web/index.ts +15 -0
  42. package/src/auth-mfa/web/mfa-client.ts +60 -0
  43. package/src/auth-mfa/web/mfa-enable-screen.tsx +187 -0
  44. package/src/auth-mfa/web/mfa-verify-screen.tsx +106 -0
  45. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +132 -0
  46. package/src/auth-mfa-user-data/hooks.ts +53 -0
  47. package/src/auth-mfa-user-data/index.ts +21 -0
  48. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +4 -0
  49. package/src/legal-pages/feature.ts +19 -28
  50. package/src/managed-pages/feature.ts +1 -1
  51. package/src/sessions/feature.ts +2 -1
  52. package/src/sessions/session-callbacks.ts +23 -0
  53. package/src/shared/index.ts +1 -0
  54. package/src/{auth-email-password → shared}/token-burn-store.ts +1 -1
@@ -0,0 +1,106 @@
1
+ // @runtime client
2
+ // Login-Challenge-Step, gerendert wenn LoginScreen's onMfaChallenge feuert
3
+ // (/auth/login antwortete mfaRequired). Stilistisch wie login-screen.tsx:
4
+ // useState + direkter fetch-Call, kein Dispatcher — es existiert noch kein
5
+ // JWT. Nach Erfolg ruft es session.refresh() statt eines Hard-Reloads, weil
6
+ // der Aufrufer (typisch ein Wizard-State im App-Root) sofort neu rendern
7
+ // will, sobald status:"authenticated" ankommt.
8
+
9
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
10
+ import { type FormEvent, type ReactNode, useState } from "react";
11
+ // kumiko-lint-ignore cross-feature-import client-only barrel, the feature's server barrel has no web/ re-export
12
+ import { AuthCard, useSession } from "../../auth-email-password/web";
13
+ import { verifyMfaChallenge } from "./mfa-client";
14
+
15
+ export type MfaVerifyScreenProps = {
16
+ readonly challengeToken: string;
17
+ readonly title?: string;
18
+ readonly subtitle?: ReactNode;
19
+ readonly submitLabel?: string;
20
+ /** Called after the server confirms the code and the session state has
21
+ * refreshed. Optional — apps that swap this screen back out purely on
22
+ * session.status changing to "authenticated" don't need it. */
23
+ readonly onSuccess?: () => void;
24
+ };
25
+
26
+ function reasonToKey(reason: string): string {
27
+ switch (reason) {
28
+ case "invalid_totp_code":
29
+ case "invalid_recovery_code":
30
+ case "invalid_code":
31
+ return "auth.mfa.errors.invalidCode";
32
+ case "challenge_expired":
33
+ case "invalid_challenge_token":
34
+ return "auth.mfa.errors.challengeExpired";
35
+ case "too_many_attempts":
36
+ return "auth.mfa.errors.tooManyAttempts";
37
+ case "rate_limited":
38
+ return "auth.errors.rateLimited";
39
+ default:
40
+ return "auth.mfa.errors.verifyFailed";
41
+ }
42
+ }
43
+
44
+ export function MfaVerifyScreen({
45
+ challengeToken,
46
+ title,
47
+ subtitle,
48
+ submitLabel,
49
+ onSuccess,
50
+ }: MfaVerifyScreenProps): ReactNode {
51
+ const t = useTranslation();
52
+ const { Form, Field, Input, Button, Banner } = usePrimitives();
53
+ const session = useSession();
54
+ const [code, setCode] = useState("");
55
+ const [submitting, setSubmitting] = useState(false);
56
+ const [error, setError] = useState<string | null>(null);
57
+
58
+ const doSubmit = async (): Promise<void> => {
59
+ setSubmitting(true);
60
+ setError(null);
61
+ const res = await verifyMfaChallenge(challengeToken, code);
62
+ if (res.kind === "success") {
63
+ await session.refresh();
64
+ setSubmitting(false);
65
+ onSuccess?.();
66
+ return;
67
+ }
68
+ setSubmitting(false);
69
+ setError(res.error.reason);
70
+ };
71
+
72
+ const onSubmit = (e?: FormEvent): void => {
73
+ e?.preventDefault();
74
+ void doSubmit();
75
+ };
76
+
77
+ return (
78
+ <AuthCard
79
+ title={title ?? t("auth.mfa.verify.title")}
80
+ subtitle={subtitle ?? t("auth.mfa.verify.subtitle")}
81
+ >
82
+ <div className="p-6 pt-0 flex flex-col gap-4">
83
+ <Form onSubmit={onSubmit}>
84
+ <Field id="mfa-verify-code" label={t("auth.mfa.verify.code")} required>
85
+ <Input
86
+ kind="text"
87
+ id="mfa-verify-code"
88
+ name="mfa-verify-code"
89
+ value={code}
90
+ onChange={setCode}
91
+ disabled={submitting}
92
+ required
93
+ autoComplete="one-time-code"
94
+ />
95
+ </Field>
96
+ {error !== null ? <Banner variant="error">{t(reasonToKey(error))}</Banner> : null}
97
+ <Button type="submit" loading={submitting} disabled={submitting}>
98
+ {submitting
99
+ ? t("auth.mfa.verify.submitting")
100
+ : (submitLabel ?? t("auth.mfa.verify.submit"))}
101
+ </Button>
102
+ </Form>
103
+ </div>
104
+ </AuthCard>
105
+ );
106
+ }
@@ -0,0 +1,132 @@
1
+ // userMfaExportHook/userMfaDeleteHook — GDPR export must never surface the
2
+ // TOTP secret or recovery codes, and forget must actually remove the row
3
+ // (not just leave it dangling for a future projection rebuild to resurrect).
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
7
+ import {
8
+ createTestUser,
9
+ setupTestStack,
10
+ type TestStack,
11
+ unsafeCreateEntityTable,
12
+ unsafePushTables,
13
+ } from "@cosmicdrift/kumiko-framework/stack";
14
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
15
+ import { base32Decode } from "../../auth-mfa/base32";
16
+ import { AuthMfaHandlers } from "../../auth-mfa/constants";
17
+ import { createAuthMfaFeature } from "../../auth-mfa/feature";
18
+ import { userMfaEntity } from "../../auth-mfa/schema/user-mfa";
19
+ import { currentTotpCode } from "../../auth-mfa/totp";
20
+ import { createConfigFeature } from "../../config";
21
+ import { createConfigResolver } from "../../config/resolver";
22
+ import { configValuesTable } from "../../config/table";
23
+ import { createUserFeature } from "../../user/feature";
24
+ import { userEntity } from "../../user/schema/user";
25
+ import { userMfaDeleteHook, userMfaExportHook } from "../hooks";
26
+
27
+ let stack: TestStack;
28
+
29
+ const SETUP_TOKEN_SECRET = "test-setup-token-secret-do-not-use-in-prod";
30
+
31
+ beforeAll(async () => {
32
+ const encryption = createTestEnvelopeCipher();
33
+ configureEntityFieldEncryption(encryption);
34
+ const resolver = createConfigResolver({ cipher: encryption });
35
+ stack = await setupTestStack({
36
+ features: [
37
+ createConfigFeature(),
38
+ createUserFeature(),
39
+ createAuthMfaFeature({
40
+ setupTokenSecret: SETUP_TOKEN_SECRET,
41
+ issuer: "Kumiko Test",
42
+ challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
43
+ }),
44
+ ],
45
+ extraContext: { configResolver: resolver, configEncryption: encryption },
46
+ });
47
+ await unsafeCreateEntityTable(stack.db, userEntity);
48
+ await unsafeCreateEntityTable(stack.db, userMfaEntity);
49
+ await unsafePushTables(stack.db, { configValuesTable });
50
+ });
51
+
52
+ afterAll(async () => {
53
+ await stack.cleanup();
54
+ });
55
+
56
+ async function enrollUser(userId: string) {
57
+ const user = createTestUser({ id: userId, roles: ["User"] });
58
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
59
+ AuthMfaHandlers.enableStart,
60
+ { accountLabel: `${userId}@example.com` },
61
+ user,
62
+ );
63
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
64
+ const secret = base32Decode(secretParam);
65
+ await stack.http.writeOk(
66
+ AuthMfaHandlers.enableConfirm,
67
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
68
+ user,
69
+ );
70
+ return user;
71
+ }
72
+
73
+ describe("userMfaExportHook / userMfaDeleteHook", () => {
74
+ test("export confirms enrollment without leaking the TOTP secret or recovery codes", async () => {
75
+ const user = await enrollUser("export-1");
76
+
77
+ const snippet = await userMfaExportHook({
78
+ db: stack.db,
79
+ registry: stack.registry,
80
+ tenantId: user.tenantId,
81
+ userId: user.id,
82
+ });
83
+
84
+ expect(snippet).not.toBeNull();
85
+ expect(snippet?.rows).toHaveLength(1);
86
+ const row = snippet?.rows[0] as Record<string, unknown>;
87
+ expect(row["enrolled"]).toBe(true);
88
+ expect(JSON.stringify(row)).not.toContain("totpSecret");
89
+ expect(JSON.stringify(row)).not.toContain("recoveryCodes");
90
+ });
91
+
92
+ test("export returns null for a user who never enrolled", async () => {
93
+ const user = createTestUser({ id: "never-enrolled", roles: ["User"] });
94
+ const snippet = await userMfaExportHook({
95
+ db: stack.db,
96
+ registry: stack.registry,
97
+ tenantId: user.tenantId,
98
+ userId: user.id,
99
+ });
100
+ expect(snippet).toBeNull();
101
+ });
102
+
103
+ test("delete hard-removes the row via forget (rebuild-safe)", async () => {
104
+ const user = await enrollUser("delete-1");
105
+
106
+ const before = await userMfaExportHook({
107
+ db: stack.db,
108
+ registry: stack.registry,
109
+ tenantId: user.tenantId,
110
+ userId: user.id,
111
+ });
112
+ expect(before).not.toBeNull();
113
+
114
+ await userMfaDeleteHook(
115
+ {
116
+ db: stack.db,
117
+ registry: stack.registry,
118
+ tenantId: user.tenantId,
119
+ userId: user.id,
120
+ },
121
+ "delete",
122
+ );
123
+
124
+ const after = await userMfaExportHook({
125
+ db: stack.db,
126
+ registry: stack.registry,
127
+ tenantId: user.tenantId,
128
+ userId: user.id,
129
+ });
130
+ expect(after).toBeNull();
131
+ });
132
+ });
@@ -0,0 +1,53 @@
1
+ // EXT_USER_DATA hooks for userMfa (GDPR Art. 15/17/20). Both totpSecret and
2
+ // recoveryCodes are marked userOwned in the schema for exactly this: on
3
+ // forget, the row must actually disappear — a leftover TOTP secret or
4
+ // recovery-code hash under an anonymized user is still a live credential
5
+ // nobody can rotate. Mirrors folders-user-data/hooks.ts's executor-based
6
+ // delete (rebuild-safe: forget replays via the event, a raw DELETE would be
7
+ // resurrected by a projection rebuild).
8
+
9
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
11
+ import {
12
+ createSystemUser,
13
+ type UserDataDeleteHook,
14
+ type UserDataExportHook,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { userMfaEntity, userMfaTable } from "../auth-mfa";
17
+
18
+ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
19
+ entityName: "user-mfa",
20
+ });
21
+
22
+ // Never includes totpSecret/recoveryCodes — secret-derived credential
23
+ // material, not portable personal data (same reasoning as api-token's
24
+ // tokenHash exclusion in user-data-rights-defaults). Just confirms
25
+ // enrollment + when, which is what a data subject actually needs to see.
26
+ export const userMfaExportHook: UserDataExportHook = async (ctx) => {
27
+ const rows = await selectMany<{ id: string; enabledAt: unknown }>(ctx.db, userMfaTable, {
28
+ userId: ctx.userId,
29
+ tenantId: ctx.tenantId,
30
+ });
31
+ if (rows.length === 0) return null;
32
+ return {
33
+ entity: "user-mfa",
34
+ rows: rows.map((r) => ({ enrolled: true, enabledAt: String(r.enabledAt ?? "") })),
35
+ };
36
+ };
37
+
38
+ // Always a real hard-delete regardless of the incoming strategy — a 2FA
39
+ // secret can't be anonymized, and userOwned already means this row belongs
40
+ // to exactly one user (never shared tenant data).
41
+ export const userMfaDeleteHook: UserDataDeleteHook = async (ctx) => {
42
+ const rows = await selectMany<{ id: string }>(ctx.db, userMfaTable, {
43
+ userId: ctx.userId,
44
+ tenantId: ctx.tenantId,
45
+ });
46
+ // skip: nothing enrolled for this user — no row to erase
47
+ if (rows.length === 0) return;
48
+ const systemUser = createSystemUser(ctx.tenantId);
49
+ const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
50
+ for (const row of rows) {
51
+ await executor.forget({ id: row.id }, systemUser, tdb);
52
+ }
53
+ };
@@ -0,0 +1,21 @@
1
+ // Provides the EXT_USER_DATA export/delete hooks for the `user-mfa` entity as
2
+ // a standalone feature — mount it alongside `auth-mfa` + `user-data-rights`
3
+ // when an app needs 2FA enrollment in its GDPR export/forget pipeline. Kept
4
+ // separate from `auth-mfa` (which only requires "user"/"config") so auth-mfa
5
+ // consumers without the user-data-rights stack don't pull a hard dependency.
6
+ // Mirrors folders-user-data.
7
+
8
+ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { userMfaDeleteHook, userMfaExportHook } from "./hooks";
10
+
11
+ export const authMfaUserDataFeature = defineFeature("auth-mfa-user-data", (r) => {
12
+ r.describe(
13
+ "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `auth-mfa` feature's `user-mfa` entity. Mounts the EXT_USER_DATA export + delete hooks so 2FA enrollment status is included in the user-data export bundle and a user's TOTP secret + recovery codes are hard-deleted (via executor.forget, rebuild-safe) on a data-subject erasure request. Kept separate from `auth-mfa` so consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `auth-mfa`.",
14
+ );
15
+ r.requires("user-data-rights");
16
+ r.optionalRequires("auth-mfa");
17
+ r.useExtension(EXT_USER_DATA, "user-mfa", {
18
+ export: userMfaExportHook,
19
+ delete: userMfaDeleteHook,
20
+ });
21
+ });
@@ -238,6 +238,10 @@ describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", ()
238
238
  // Resolver gibt IMMER einen anderen Tenant zurück — wenn legal-
239
239
  // pages den respektieren würde, wäre der DB-Lookup leer.
240
240
  tenantResolver: () => otherTenantId,
241
+ // Mirrors publicstatus's real subdomain resolver: the tenant is
242
+ // derived from the host, which the client cannot forge — trusted
243
+ // over any client-supplied tenant.
244
+ resolverTrust: "authoritative",
241
245
  tenantExists: async (id) => id === otherTenantId || id === SYSTEM_TENANT_ID,
242
246
  },
243
247
  extraContext: ({ db }) => ({
@@ -21,10 +21,8 @@ import { renderMarkdownToHtml, wrapInLayout } from "./markdown";
21
21
  // wie bei jedem API-Endpunkt.
22
22
  const TEXT_CONTENT_BY_SLUG_QN = "text-content:query:by-slug";
23
23
 
24
- // Wire-Body-Shape von /api/query — das, was bySlugQuery returnt.
25
- type ByslugQueryBody = {
26
- data: { title: string; body: string | null; updatedAt: string } | null;
27
- };
24
+ // Return-Shape von bySlugQuery (text-content/handlers/by-slug.query.ts).
25
+ type TextBlockQueryResult = { title: string; body: string | null; updatedAt: string } | null;
28
26
 
29
27
  // 60s-shared-cache saves the origin-revalidate roundtrip; legal-content edits are live within 60s.
30
28
  const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
@@ -81,35 +79,28 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
81
79
  method: "GET",
82
80
  path: route.path,
83
81
  anonymous: true,
84
- handler: async (c, { app }) => {
85
- const url = new URL(c.req.url);
82
+ handler: async (c, { systemQuery }) => {
86
83
  // Architektur: 1 App = X Tenants = 1 Impressum. Egal welche
87
84
  // Subdomain der Visitor besucht (apex, admin.*, tenant-x.*) —
88
- // legal-pages serven IMMER die SYSTEM_TENANT-Texte. Deshalb
89
- // explizit X-Tenant-Header setzen statt host weiterreichen
90
- // (sonst würde ein host-basierter anonymousAccess-Resolver
91
- // die tenant-Subdomain auf tenant-tenantId resolven und
92
- // tenant-x's leere imprint-Tabelle abfragen → 404).
93
- const queryRes = await app.fetch(
94
- new Request(`${url.origin}/api/query`, {
95
- method: "POST",
96
- headers: {
97
- "content-type": "application/json",
98
- "X-Tenant": SYSTEM_TENANT_ID,
99
- },
100
- body: JSON.stringify({
101
- type: TEXT_CONTENT_BY_SLUG_QN,
102
- payload: { slug: route.slug, lang: route.lang },
103
- }),
104
- }),
105
- );
106
-
107
- if (!queryRes.ok) {
85
+ // legal-pages serven IMMER die SYSTEM_TENANT-Texte. systemQuery
86
+ // erzwingt SYSTEM_TENANT_ID in-process (kein X-Tenant-Header-
87
+ // Trick über app.fetch — der sähe für einen host-basierten
88
+ // anonymousAccess-Resolver wie ein Client-Override aus und würde
89
+ // von resolverTrust: "authoritative" zurecht abgelehnt).
90
+ let data: TextBlockQueryResult;
91
+ try {
92
+ // @cast-boundary engine-payload — dispatcher.query returnt
93
+ // unknown; Shape ist durch bySlugQuery's Handler-Return-Type
94
+ // (siehe text-content/handlers/by-slug.query.ts) vertraut.
95
+ data = (await systemQuery(
96
+ TEXT_CONTENT_BY_SLUG_QN,
97
+ { slug: route.slug, lang: route.lang },
98
+ SYSTEM_TENANT_ID,
99
+ )) as TextBlockQueryResult;
100
+ } catch {
108
101
  return c.text("legal page unavailable", 503);
109
102
  }
110
103
 
111
- const body: ByslugQueryBody = await queryRes.json();
112
- const data = body.data;
113
104
  if (!data?.body) {
114
105
  return c.text(
115
106
  `${route.titleFallback} not configured. Tenant-Admin must set this text-block.`,
@@ -162,7 +162,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
162
162
 
163
163
  return defineFeature("managed-pages", (r) => {
164
164
  r.describe(
165
- "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
165
+ "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
166
166
  );
167
167
  r.uiHints({
168
168
  displayLabel: "Managed Pages · Public CMS",
@@ -7,7 +7,7 @@ import { revokeWrite } from "./handlers/revoke.write";
7
7
  import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
8
8
  import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
9
9
  import { userSessionEntity } from "./schema/user-session";
10
- import type { SessionMassRevoker } from "./session-callbacks";
10
+ import type { SessionAllOthersRevoker, SessionMassRevoker } from "./session-callbacks";
11
11
 
12
12
  export type SessionsFeatureOptions = {
13
13
  // A successful update on the `user` entity that changes the `passwordHash`
@@ -29,6 +29,7 @@ export type SessionsFeatureOptions = {
29
29
  };
30
30
 
31
31
  export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
32
+ export type BindRevokeAllOtherSessions = (revoker: SessionAllOthersRevoker) => void;
32
33
 
33
34
  // Reads the late-bind setter off a mounted sessions feature's exports.
34
35
  // run{Prod,Dev}App call it once the DB connection is concrete — the feature
@@ -33,6 +33,16 @@ const BLOCKED_STATUSES: ReadonlySet<UserStatus> = new Set([
33
33
  // caller can log "revoked N other sessions".
34
34
  export type SessionMassRevoker = (userId: string) => Promise<number>;
35
35
 
36
+ // "Sign out everywhere else, keep this one" — used both by the user-facing
37
+ // revoke-all-others handler and by other features (auth-mfa) that need the
38
+ // same effect from a raw callback instead of a dispatcher round-trip.
39
+ // currentSid undefined (stateless-JWT / no sid claim) revokes everything —
40
+ // there is no "current" row to spare.
41
+ export type SessionAllOthersRevoker = (
42
+ userId: string,
43
+ currentSid: string | undefined,
44
+ ) => Promise<number>;
45
+
36
46
  export type SessionCallbacksOptions = {
37
47
  readonly db: DbConnection;
38
48
  // Session lifetime. MVP uses a single flat window; per-app policies can
@@ -45,6 +55,7 @@ export type SessionCallbacks = {
45
55
  sessionRevoker: SessionRevoker;
46
56
  sessionChecker: SessionChecker;
47
57
  sessionMassRevoker: SessionMassRevoker;
58
+ sessionRevokeAllOthers: SessionAllOthersRevoker;
48
59
  };
49
60
 
50
61
  export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCallbacks {
@@ -135,5 +146,17 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
135
146
  );
136
147
  return result.length;
137
148
  },
149
+
150
+ async sessionRevokeAllOthers(userId: string, currentSid: string | undefined): Promise<number> {
151
+ const result = await updateMany(
152
+ db,
153
+ userSessionTable,
154
+ { revokedAt: Temporal.Now.instant() },
155
+ currentSid
156
+ ? { userId, revokedAt: null, id: { ne: currentSid } }
157
+ : { userId, revokedAt: null },
158
+ );
159
+ return result.length;
160
+ },
138
161
  };
139
162
  }
@@ -9,3 +9,4 @@ export { decryptStoredPii } from "./decrypt-stored-pii";
9
9
  export { encryptForDirectWrite } from "./encrypt-for-direct-write";
10
10
  export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
11
11
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
12
+ export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
@@ -1,5 +1,5 @@
1
1
  // Single-use enforcement for HMAC-signed auth tokens (password-reset,
2
- // email-verification).
2
+ // email-verification, MFA setup/challenge).
3
3
  //
4
4
  // Problem: the token itself carries only userId + expiry + signature.
5
5
  // Without server-side burn, the same token can be replayed within its