@cosmicdrift/kumiko-framework 0.160.0 → 0.162.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.160.0",
3
+ "version": "0.162.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.160.0",
185
+ "@cosmicdrift/kumiko-types": "0.162.0",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.18",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.160.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.162.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -177,6 +177,27 @@ describe("runSchemaCli — validate (static CI gate, no DB)", () => {
177
177
  const code = await runSchemaCli(["validate"], appCwd, cap.out);
178
178
  expect(code).toBe(0);
179
179
  expect(cap.log.join("\n")).toContain("migrations match");
180
+ expect(cap.log.join("\n")).toContain("committed SQL matches .snapshot.json");
181
+ });
182
+
183
+ // Reproduces the kumiko-studio 0016 incident: a migration file gets
184
+ // hand-edited (or copy-pasted from another file) after `generate` wrote
185
+ // it — the snapshot still matches ENTITY_METAS (layer 1 stays green), but
186
+ // the .sql content no longer produces what the snapshot claims.
187
+ test("migration file edited after generate to not match its snapshot entry → drift, exit 1", async () => {
188
+ writeSchemaFile(appCwd, "read_widgets");
189
+ await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
190
+ writeFileSync(
191
+ join(appCwd, "kumiko/migrations/0001_init.sql"),
192
+ `-- oops: hand-edited to the wrong table name after the snapshot was written
193
+ CREATE TABLE IF NOT EXISTS "read_widgets_v2" ("id" uuid PRIMARY KEY);
194
+ `,
195
+ );
196
+ const cap = captureOut();
197
+ const code = await runSchemaCli(["validate"], appCwd, cap.out);
198
+ expect(code).toBe(1);
199
+ expect(cap.err.join("\n")).toContain("migration-content drift");
200
+ expect(cap.err.join("\n")).toContain("read_widgets");
180
201
  });
181
202
 
182
203
  test("no FEATURES export → validateBoot skipped (drift still checked)", async () => {
@@ -0,0 +1,205 @@
1
+ // auth-routes /auth/mfa/preauth-confirm — framework-level route mechanics
2
+ // only: body validation, dispatch-and-mint-on-success, its OWN rate limiter
3
+ // (mfaPreauthConfirmRateLimit, distinct from mfaVerifyRateLimit/
4
+ // loginRateLimit), and error-status mapping. Uses a stub Dispatcher with a
5
+ // fake handler — the REAL setupToken verification / TOTP check / brute-
6
+ // force cap lives in auth-mfa's own handler and is covered there, not here.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { Hono } from "hono";
10
+ import { Hono as HonoCtor } from "hono";
11
+ import { InternalError, UnprocessableError } from "../../errors";
12
+ import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
13
+ import { TestUsers } from "../../stack";
14
+ import { getSetCookies } from "../../testing/http-cookies";
15
+ import { PUBLIC_API_PATHS } from "../api-constants";
16
+ import { AUTH_COOKIE_NAME, authMiddleware, CSRF_COOKIE_NAME } from "../auth-middleware";
17
+ import {
18
+ type AuthRoutesConfig,
19
+ createAuthRoutes,
20
+ createInMemoryLoginRateLimiter,
21
+ } from "../auth-routes";
22
+ import { createJwtHelper } from "../jwt";
23
+
24
+ const JWT_SECRET = "test-jwt-secret-for-mfa-preauth-confirm-route-tests-only-not-a-real-secret";
25
+ const PREAUTH_CONFIRM_QN = "auth-mfa:write:enable-confirm-preauth";
26
+
27
+ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
28
+ const base: Dispatcher = {
29
+ async write(): Promise<WriteResult> {
30
+ const ok: WriteResult = {
31
+ isSuccess: true,
32
+ data: { kind: "mfa-preauth-confirm-success", session: TestUsers.user },
33
+ };
34
+ return ok;
35
+ },
36
+ async query(): Promise<unknown> {
37
+ return [];
38
+ },
39
+ async *stream(): AsyncGenerator<unknown> {},
40
+ async command(): Promise<void> {},
41
+ async batch(): Promise<BatchResult> {
42
+ const ok: BatchResult = { isSuccess: true, results: [] };
43
+ return ok;
44
+ },
45
+ async resolveAuthClaims(): Promise<Record<string, unknown>> {
46
+ return {};
47
+ },
48
+ };
49
+ return { ...base, ...overrides };
50
+ }
51
+
52
+ async function buildApp(
53
+ overrides: Partial<AuthRoutesConfig> = {},
54
+ dispatcher: Dispatcher = createStubDispatcher(),
55
+ ): Promise<{ app: Hono }> {
56
+ const jwt = createJwtHelper(JWT_SECRET);
57
+ const config: AuthRoutesConfig = {
58
+ membershipQuery: "tenant:query:memberships",
59
+ mfaPreauthConfirmHandler: PREAUTH_CONFIRM_QN,
60
+ mfaPreauthConfirmRateLimit: null,
61
+ ...overrides,
62
+ };
63
+ const app = new HonoCtor();
64
+ const jwtGuard = authMiddleware(jwt);
65
+ app.use("/api/*", async (c, next) => {
66
+ if (PUBLIC_API_PATHS.has(c.req.path)) return next();
67
+ return jwtGuard(c, next);
68
+ });
69
+ app.route("/api", createAuthRoutes(dispatcher, jwt, config));
70
+ return { app };
71
+ }
72
+
73
+ function preauthConfirmRequest(body: unknown): Request {
74
+ return new Request("http://localhost/api/auth/mfa/preauth-confirm", {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: JSON.stringify(body),
78
+ });
79
+ }
80
+
81
+ describe("POST /auth/mfa/preauth-confirm", () => {
82
+ test("is public — reachable without a JWT", async () => {
83
+ expect(PUBLIC_API_PATHS.has("/api/auth/mfa/preauth-confirm")).toBe(true);
84
+ });
85
+
86
+ test("not mounted when mfaPreauthConfirmHandler is unset", async () => {
87
+ const { app } = await buildApp({ mfaPreauthConfirmHandler: undefined });
88
+ const res = await app.request(preauthConfirmRequest({ setupToken: "t", code: "123456" }));
89
+ expect(res.status).toBe(404);
90
+ });
91
+
92
+ test("400 on a malformed body, before dispatch or rate-limit", async () => {
93
+ let dispatched = false;
94
+ const dispatcher = createStubDispatcher({
95
+ async write(): Promise<WriteResult> {
96
+ dispatched = true;
97
+ return {
98
+ isSuccess: true,
99
+ data: { kind: "mfa-preauth-confirm-success", session: TestUsers.user },
100
+ };
101
+ },
102
+ });
103
+ const { app } = await buildApp({}, dispatcher);
104
+ const res = await app.request(preauthConfirmRequest({ setupToken: "t" }));
105
+ expect(res.status).toBe(400);
106
+ const body = (await res.json()) as { isSuccess: boolean; error: string };
107
+ expect(body.error).toBe("invalid_body");
108
+ expect(dispatched).toBe(false);
109
+ });
110
+
111
+ test("on success: dispatches to mfaPreauthConfirmHandler, mints a JWT + cookies", async () => {
112
+ let receivedBody: unknown;
113
+ const dispatcher = createStubDispatcher({
114
+ async write(qn, payload): Promise<WriteResult> {
115
+ expect(qn).toBe(PREAUTH_CONFIRM_QN);
116
+ receivedBody = payload;
117
+ return {
118
+ isSuccess: true,
119
+ data: { kind: "mfa-preauth-confirm-success", session: TestUsers.user },
120
+ };
121
+ },
122
+ });
123
+ const { app } = await buildApp({}, dispatcher);
124
+ const res = await app.request(
125
+ preauthConfirmRequest({ setupToken: "secret-carrying-setup-token", code: "123456" }),
126
+ );
127
+ expect(res.status).toBe(200);
128
+ expect(receivedBody).toEqual({
129
+ setupToken: "secret-carrying-setup-token",
130
+ code: "123456",
131
+ });
132
+
133
+ const body = (await res.json()) as { isSuccess: boolean; token: string };
134
+ expect(body.isSuccess).toBe(true);
135
+ expect(typeof body.token).toBe("string");
136
+ expect(body.token.length).toBeGreaterThan(20);
137
+
138
+ const cookies = getSetCookies(res);
139
+ expect(cookies.get(AUTH_COOKIE_NAME)).toBeDefined();
140
+ expect(cookies.get(CSRF_COOKIE_NAME)).toBeDefined();
141
+ });
142
+
143
+ test("a handler failure maps through mfaPreauthConfirmErrorStatusMap", async () => {
144
+ const dispatcher = createStubDispatcher({
145
+ async write(): Promise<WriteResult> {
146
+ return {
147
+ isSuccess: false,
148
+ error: new UnprocessableError("invalid_totp_code", {
149
+ details: { reason: "invalid_totp_code" },
150
+ }),
151
+ };
152
+ },
153
+ });
154
+ const { app } = await buildApp(
155
+ { mfaPreauthConfirmErrorStatusMap: { invalid_totp_code: 422 } },
156
+ dispatcher,
157
+ );
158
+ const res = await app.request(preauthConfirmRequest({ setupToken: "t", code: "000000" }));
159
+ expect(res.status).toBe(422);
160
+ const body = (await res.json()) as { isSuccess: boolean };
161
+ expect(body.isSuccess).toBe(false);
162
+ });
163
+
164
+ test("an unmapped handler failure falls back to the error's own httpStatus", async () => {
165
+ const dispatcher = createStubDispatcher({
166
+ async write(): Promise<WriteResult> {
167
+ return { isSuccess: false, error: new InternalError({ message: "boom" }) };
168
+ },
169
+ });
170
+ const { app } = await buildApp({}, dispatcher);
171
+ const res = await app.request(preauthConfirmRequest({ setupToken: "t", code: "000000" }));
172
+ expect(res.status).toBe(500);
173
+ });
174
+
175
+ test("mfaPreauthConfirmRateLimit is independent from mfaVerifyRateLimit — 429 after its own cap", async () => {
176
+ const dispatcher = createStubDispatcher({
177
+ async write(): Promise<WriteResult> {
178
+ return {
179
+ isSuccess: false,
180
+ error: new UnprocessableError("invalid_totp_code"),
181
+ };
182
+ },
183
+ });
184
+ const { app } = await buildApp(
185
+ { mfaPreauthConfirmRateLimit: createInMemoryLoginRateLimiter(2, 60_000) },
186
+ dispatcher,
187
+ );
188
+ const attempt = () => app.request(preauthConfirmRequest({ setupToken: "t", code: "000000" }));
189
+ expect((await attempt()).status).toBe(422);
190
+ expect((await attempt()).status).toBe(422);
191
+ const third = await attempt();
192
+ expect(third.status).toBe(429);
193
+ const body = (await third.json()) as { isSuccess: boolean; error: string };
194
+ expect(body.error).toBe("rate_limited");
195
+ });
196
+
197
+ test("a successful confirm resets the rate-limit counter for that IP", async () => {
198
+ const { app } = await buildApp({
199
+ mfaPreauthConfirmRateLimit: createInMemoryLoginRateLimiter(1, 60_000),
200
+ });
201
+ const attempt = () => app.request(preauthConfirmRequest({ setupToken: "t", code: "000000" }));
202
+ expect((await attempt()).status).toBe(200);
203
+ expect((await attempt()).status).toBe(200);
204
+ });
205
+ });
@@ -0,0 +1,186 @@
1
+ // auth-routes /auth/mfa/preauth-enable-start — framework-level route
2
+ // mechanics only: body validation, dispatch-without-minting-a-session, and
3
+ // error-status mapping. Uses a stub Dispatcher with a fake handler — the
4
+ // REAL preauthSetupToken verification / secret generation lives in
5
+ // auth-mfa's own handler and is covered there, not here.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type { Hono } from "hono";
9
+ import { Hono as HonoCtor } from "hono";
10
+ import { InternalError, UnprocessableError } from "../../errors";
11
+ import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
12
+ import { PUBLIC_API_PATHS } from "../api-constants";
13
+ import { authMiddleware } from "../auth-middleware";
14
+ import { type AuthRoutesConfig, createAuthRoutes } from "../auth-routes";
15
+ import { createJwtHelper } from "../jwt";
16
+
17
+ const JWT_SECRET = "test-jwt-secret-at-least-32-bytes-long!!";
18
+ const PREAUTH_ENABLE_START_QN = "auth-mfa:write:enable-start-preauth";
19
+
20
+ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
21
+ const base: Dispatcher = {
22
+ async write(): Promise<WriteResult> {
23
+ const ok: WriteResult = {
24
+ isSuccess: true,
25
+ data: {
26
+ setupToken: "stub-setup-token",
27
+ otpauthUri: "otpauth://totp/stub",
28
+ recoveryCodes: ["AAAA-BBBB"],
29
+ },
30
+ };
31
+ return ok;
32
+ },
33
+ async query(): Promise<unknown> {
34
+ return [];
35
+ },
36
+ async *stream(): AsyncGenerator<unknown> {},
37
+ async command(): Promise<void> {},
38
+ async batch(): Promise<BatchResult> {
39
+ const ok: BatchResult = { isSuccess: true, results: [] };
40
+ return ok;
41
+ },
42
+ async resolveAuthClaims(): Promise<Record<string, unknown>> {
43
+ return {};
44
+ },
45
+ };
46
+ return { ...base, ...overrides };
47
+ }
48
+
49
+ async function buildApp(
50
+ overrides: Partial<AuthRoutesConfig> = {},
51
+ dispatcher: Dispatcher = createStubDispatcher(),
52
+ ): Promise<{ app: Hono }> {
53
+ const jwt = createJwtHelper(JWT_SECRET);
54
+ const config: AuthRoutesConfig = {
55
+ membershipQuery: "tenant:query:memberships",
56
+ mfaPreauthEnableStartHandler: PREAUTH_ENABLE_START_QN,
57
+ ...overrides,
58
+ };
59
+ const app = new HonoCtor();
60
+ const jwtGuard = authMiddleware(jwt);
61
+ app.use("/api/*", async (c, next) => {
62
+ if (PUBLIC_API_PATHS.has(c.req.path)) return next();
63
+ return jwtGuard(c, next);
64
+ });
65
+ app.route("/api", createAuthRoutes(dispatcher, jwt, config));
66
+ return { app };
67
+ }
68
+
69
+ function preauthStartRequest(body: unknown): Request {
70
+ return new Request("http://localhost/api/auth/mfa/preauth-enable-start", {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/json" },
73
+ body: JSON.stringify(body),
74
+ });
75
+ }
76
+
77
+ describe("POST /auth/mfa/preauth-enable-start", () => {
78
+ test("is public — reachable without a JWT", async () => {
79
+ expect(PUBLIC_API_PATHS.has("/api/auth/mfa/preauth-enable-start")).toBe(true);
80
+ });
81
+
82
+ test("not mounted when mfaPreauthEnableStartHandler is unset", async () => {
83
+ const { app } = await buildApp({ mfaPreauthEnableStartHandler: undefined });
84
+ const res = await app.request(
85
+ preauthStartRequest({ preauthSetupToken: "t", accountLabel: "a@b.c" }),
86
+ );
87
+ expect(res.status).toBe(404);
88
+ });
89
+
90
+ test("400 on a malformed body, before dispatch", async () => {
91
+ let dispatched = false;
92
+ const dispatcher = createStubDispatcher({
93
+ async write(): Promise<WriteResult> {
94
+ dispatched = true;
95
+ return {
96
+ isSuccess: true,
97
+ data: { setupToken: "x", otpauthUri: "otpauth://totp/x", recoveryCodes: [] },
98
+ };
99
+ },
100
+ });
101
+ const { app } = await buildApp({}, dispatcher);
102
+ const res = await app.request(preauthStartRequest({ preauthSetupToken: "t" }));
103
+ expect(res.status).toBe(400);
104
+ const body = (await res.json()) as { isSuccess: boolean; error: string };
105
+ expect(body.error).toBe("invalid_body");
106
+ expect(dispatched).toBe(false);
107
+ });
108
+
109
+ test("on success: dispatches to the handler, returns setupToken/otpauthUri/recoveryCodes, mints NO session", async () => {
110
+ let receivedBody: unknown;
111
+ const dispatcher = createStubDispatcher({
112
+ async write(qn, payload): Promise<WriteResult> {
113
+ expect(qn).toBe(PREAUTH_ENABLE_START_QN);
114
+ receivedBody = payload;
115
+ return {
116
+ isSuccess: true,
117
+ data: {
118
+ setupToken: "secret-carrying-setup-token",
119
+ otpauthUri: "otpauth://totp/Kumiko:a%40b.c",
120
+ recoveryCodes: ["AAAA-BBBB", "CCCC-DDDD"],
121
+ },
122
+ };
123
+ },
124
+ });
125
+ const { app } = await buildApp({}, dispatcher);
126
+ const res = await app.request(
127
+ preauthStartRequest({ preauthSetupToken: "opaque-preauth-token", accountLabel: "a@b.c" }),
128
+ );
129
+ expect(res.status).toBe(200);
130
+ expect(receivedBody).toEqual({
131
+ preauthSetupToken: "opaque-preauth-token",
132
+ accountLabel: "a@b.c",
133
+ });
134
+
135
+ const body = (await res.json()) as {
136
+ isSuccess: boolean;
137
+ setupToken: string;
138
+ otpauthUri: string;
139
+ recoveryCodes: string[];
140
+ token?: string;
141
+ };
142
+ expect(body.isSuccess).toBe(true);
143
+ expect(body.setupToken).toBe("secret-carrying-setup-token");
144
+ expect(body.otpauthUri).toBe("otpauth://totp/Kumiko:a%40b.c");
145
+ expect(body.recoveryCodes).toEqual(["AAAA-BBBB", "CCCC-DDDD"]);
146
+ // No JWT minted — unlike /auth/login and /auth/mfa/verify, this route
147
+ // never establishes a session.
148
+ expect(body.token).toBeUndefined();
149
+ });
150
+
151
+ test("a handler failure maps through mfaPreauthEnableStartErrorStatusMap", async () => {
152
+ const dispatcher = createStubDispatcher({
153
+ async write(): Promise<WriteResult> {
154
+ return {
155
+ isSuccess: false,
156
+ error: new UnprocessableError("invalid_challenge_token", {
157
+ details: { reason: "invalid_challenge_token" },
158
+ }),
159
+ };
160
+ },
161
+ });
162
+ const { app } = await buildApp(
163
+ { mfaPreauthEnableStartErrorStatusMap: { invalid_challenge_token: 422 } },
164
+ dispatcher,
165
+ );
166
+ const res = await app.request(
167
+ preauthStartRequest({ preauthSetupToken: "t", accountLabel: "a@b.c" }),
168
+ );
169
+ expect(res.status).toBe(422);
170
+ const body = (await res.json()) as { isSuccess: boolean };
171
+ expect(body.isSuccess).toBe(false);
172
+ });
173
+
174
+ test("an unmapped handler failure falls back to the error's own httpStatus", async () => {
175
+ const dispatcher = createStubDispatcher({
176
+ async write(): Promise<WriteResult> {
177
+ return { isSuccess: false, error: new InternalError({ message: "boom" }) };
178
+ },
179
+ });
180
+ const { app } = await buildApp({}, dispatcher);
181
+ const res = await app.request(
182
+ preauthStartRequest({ preauthSetupToken: "t", accountLabel: "a@b.c" }),
183
+ );
184
+ expect(res.status).toBe(500);
185
+ });
186
+ });
@@ -14,6 +14,8 @@ export const Routes = {
14
14
  auth: "/auth",
15
15
  authLogin: "/auth/login",
16
16
  authMfaVerify: "/auth/mfa/verify",
17
+ authMfaPreauthEnableStart: "/auth/mfa/preauth-enable-start",
18
+ authMfaPreauthConfirm: "/auth/mfa/preauth-confirm",
17
19
  authLogout: "/auth/logout",
18
20
  authTenants: "/auth/tenants",
19
21
  authSwitchTenant: "/auth/switch-tenant",
@@ -41,6 +43,8 @@ export const Routes = {
41
43
  export const PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
42
44
  `/api${Routes.authLogin}`,
43
45
  `/api${Routes.authMfaVerify}`,
46
+ `/api${Routes.authMfaPreauthEnableStart}`,
47
+ `/api${Routes.authMfaPreauthConfirm}`,
44
48
  `/api${Routes.authRequestPasswordReset}`,
45
49
  `/api${Routes.authResetPassword}`,
46
50
  `/api${Routes.authRequestEmailVerification}`,
@@ -75,7 +75,7 @@ export type AuthMiddlewareOptions = {
75
75
  // callers instead of being rejected with 401. The middleware synthesises
76
76
  // a SessionUser with id="anonymous" and roles=["anonymous"], scoped to a
77
77
  // tenantId resolved through the chain documented on AnonymousAccessConfig.
78
- readonly anonymousAccess?: AnonymousAccessConfig;
78
+ readonly anonymousAccess?: AnonymousAccessResolved;
79
79
  // Consulted after tenantId is resolved (JWT/PAT/anonymous). Returns 410
80
80
  // when the tenant is in teardown (destroyRequested/destroying/destroyed).
81
81
  // cancel-destruction is exempt while status=destroyRequested.
@@ -96,50 +96,23 @@ export type TenantResolver = (c: Context) => Promise<TenantId | null> | TenantId
96
96
  // deployments where a caller could otherwise probe arbitrary ids.
97
97
  export type TenantExists = (tenantId: TenantId) => Promise<boolean> | boolean;
98
98
 
99
- // Single-tenant shortcut. When set, the server runs in **locked** mode:
100
- // - no client-supplied tenant: defaultTenantId is used.
101
- // - client supplies a matching tenant (header/cookie/resolver): allowed.
102
- // - client supplies a non-matching tenant: 400 tenant_mismatch (the
103
- // server is single-tenant; rejecting protects against confused clients
104
- // who think they're talking to a different deployment).
105
- // The framework does NOT verify defaultTenantId against the DB at boot;
106
- // the caller is responsible (see sample for the pattern).
107
- // Per-request existence check for header/cookie/resolver-supplied ids.
108
- // Skipped for the defaultTenantId path (the caller already vetted that
109
- // value when configuring the server).
110
- type AnonymousAccessConfigCommon = {
99
+ // App-facing anonymous-access config (#1374). tenantResolver / tenantExists
100
+ // / resolverTrust come from auth-foundation providers (EXT_TENANT_RESOLVER /
101
+ // EXT_TENANT_EXISTENCE), resolved at boot into AnonymousAccessResolved.
102
+ //
103
+ // Single-tenant shortcut: when defaultTenantId is set, the server runs in
104
+ // **locked** mode (client must agree or get tenant_mismatch).
105
+ export type AnonymousAccessConfig = {
111
106
  readonly defaultTenantId?: TenantId;
112
- readonly tenantExists?: TenantExists;
113
107
  };
114
108
 
115
- // Union, not one flat optional-everything type: a tenantResolver without a
116
- // declared resolverTrust is an ambiguous trust decision the compiler should
117
- // catch, not a silent runtime default. Set resolverTrust to:
118
- // - "authoritative": the resolver is trusted (e.g. it derives the tenant
119
- // from the subdomain, which the client cannot forge) and is consulted
120
- // FIRST. A client-supplied tenant that disagrees with the resolver's
121
- // answer is rejected with 400 tenant_mismatch — it is never used to
122
- // override the resolver, and it is never used as a substitute answer
123
- // when the resolver returns null either (that would just reopen the
124
- // same override via an unrecognised host). Pick this whenever the
125
- // resolver derives the tenant from something the caller cannot control
126
- // (subdomain, mTLS cert, etc.) — the whole point of such a resolver is
127
- // defeated if a client header can still override it.
128
- // - "fallback-only": a client-supplied header/cookie wins outright; the
129
- // resolver only runs when neither is present. Pick this only when the
130
- // resolver is a pure convenience fallback for callers that never send
131
- // a tenant of their own (e.g. a bare API host with no per-tenant
132
- // subdomains) and its answer carries no more trust than the client's
133
- // own claim.
134
- export type AnonymousAccessConfig =
135
- | (AnonymousAccessConfigCommon & {
136
- readonly tenantResolver?: undefined;
137
- readonly resolverTrust?: undefined;
138
- })
139
- | (AnonymousAccessConfigCommon & {
140
- readonly tenantResolver: TenantResolver;
141
- readonly resolverTrust: "authoritative" | "fallback-only";
142
- });
109
+ // Runtime config consumed by authMiddleware after boot merges registry
110
+ // providers onto the app-facing AnonymousAccessConfig.
111
+ export type AnonymousAccessResolved = AnonymousAccessConfig & {
112
+ readonly tenantResolver?: TenantResolver;
113
+ readonly tenantExists?: TenantExists;
114
+ readonly resolverTrust?: "authoritative" | "fallback-only";
115
+ };
143
116
 
144
117
  // Where the candidate tenant came from. Drives the validation policy:
145
118
  // - header / cookie / resolver: untrusted, must pass tenantExists if set.
@@ -409,7 +382,7 @@ async function handleVerifiedBearerUser(
409
382
  // no CSRF vector to defend against).
410
383
  async function handleAnonymous(
411
384
  c: Context,
412
- config: AnonymousAccessConfig,
385
+ config: AnonymousAccessResolved,
413
386
  next: Next,
414
387
  resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver,
415
388
  ): Promise<Response | undefined> {
@@ -495,7 +468,7 @@ type ResolveError = { error: RejectArgs };
495
468
  // clients that think they're talking to a different installation.
496
469
  async function resolveTenant(
497
470
  c: Context,
498
- config: AnonymousAccessConfig,
471
+ config: AnonymousAccessResolved,
499
472
  clientTenant: { id: TenantId; source: "header" | "cookie" } | null,
500
473
  ): Promise<ResolvedTenant | ResolveError> {
501
474
  if (config.defaultTenantId !== undefined) {
@@ -106,6 +106,26 @@ const MfaVerifyBody = z.object({
106
106
  code: z.string().min(6).max(9),
107
107
  });
108
108
 
109
+ // Body schema for POST /auth/mfa/preauth-enable-start. preauthSetupToken is
110
+ // opaque to the framework — minted by login.write.ts's mfaStatusChecker
111
+ // (auth-mfa) when enforcement policy blocks an unenrolled user, verified
112
+ // entirely by the mfaPreauthEnableStartHandler. accountLabel is
113
+ // client-supplied (the email the user just typed at login) for the
114
+ // otpauth:// URI, mirroring auth-mfa's own enable-start.write.ts.
115
+ const MfaPreauthEnableStartBody = z.object({
116
+ preauthSetupToken: z.string().min(1),
117
+ accountLabel: z.string().min(1).max(200),
118
+ });
119
+
120
+ // Body schema for POST /auth/mfa/preauth-confirm. setupToken is opaque to
121
+ // the framework — minted by the mfaPreauthEnableStartHandler, verified
122
+ // entirely by the mfaPreauthConfirmHandler. code is TOTP-only (no recovery
123
+ // codes at enrollment time), mirroring auth-mfa's own enable-confirm.write.ts.
124
+ const MfaPreauthConfirmBody = z.object({
125
+ setupToken: z.string().min(1),
126
+ code: z.string().length(6),
127
+ });
128
+
109
129
  const ResetPasswordBody = z.object({
110
130
  token: z.string().min(1),
111
131
  newPassword: z.string().min(8).max(200),
@@ -248,6 +268,42 @@ export type AuthRoutesConfig = {
248
268
  // and per-account guessing protection are different threats, neither
249
269
  // substitutes for the other.
250
270
  mfaVerifyRateLimit?: LoginRateLimiter | null;
271
+ // Optional: qualified write handler generating a TOTP secret + QR for a
272
+ // user blocked at login by MFA enforcement but not yet enrolled (see
273
+ // mfaStatusChecker's setupRequired branch, auth-mfa). When set, POST
274
+ // /auth/mfa/preauth-enable-start dispatches { preauthSetupToken,
275
+ // accountLabel } to this handler with a guest identity — no session is
276
+ // minted, the response is just { setupToken, otpauthUri, recoveryCodes }
277
+ // (same shape as auth-mfa's own enable-start.write.ts). No dedicated
278
+ // rate-limiter here (unlike mfaVerifyRateLimit): the route already
279
+ // inherits the generic L2 authEndpointRateLimit on /api/auth/*, and this
280
+ // handler checks no TOTP code, so there's no per-account guessing
281
+ // surface to cap — that cap belongs on the later confirm handler.
282
+ mfaPreauthEnableStartHandler?: string;
283
+ // Maps mfaPreauthEnableStartHandler error codes to HTTP status codes,
284
+ // same pattern as mfaVerifyErrorStatusMap.
285
+ mfaPreauthEnableStartErrorStatusMap?: Readonly<Record<string, number>>;
286
+ // Optional: qualified write handler completing the enrollment started by
287
+ // mfaPreauthEnableStartHandler — takes the secret-carrying setupToken
288
+ // from that step plus a TOTP code. When set, POST /auth/mfa/preauth-
289
+ // confirm dispatches { setupToken, code } to this handler with a guest
290
+ // identity. On success the handler must return { kind: "mfa-preauth-
291
+ // confirm-success", session: SessionUser } and the route mints a JWT
292
+ // exactly like /auth/mfa/verify — this call both finishes enrollment AND
293
+ // completes the login that was blocked by mfa-setup-required. Unlike
294
+ // mfaPreauthEnableStartHandler, THIS is the per-account TOTP-guessing
295
+ // surface the enable-start doc comment refers to — the handler owns its
296
+ // own brute-force cap (mirroring mfaVerifyHandler's), and this route adds
297
+ // its own IP-scoped rate limiter for the same reason mfaVerifyRateLimit
298
+ // exists separately from loginRateLimit.
299
+ mfaPreauthConfirmHandler?: string;
300
+ // Maps mfaPreauthConfirmHandler error codes to HTTP status codes, same
301
+ // pattern as mfaVerifyErrorStatusMap.
302
+ mfaPreauthConfirmErrorStatusMap?: Readonly<Record<string, number>>;
303
+ // Rate-limit for POST /auth/mfa/preauth-confirm, keyed by client IP. Same
304
+ // reasoning as mfaVerifyRateLimit — defaults to in-memory 10/5min, pass
305
+ // `null` to disable.
306
+ mfaPreauthConfirmRateLimit?: LoginRateLimiter | null;
251
307
  // Session-lifecycle callbacks. When both are wired the JWT carries a `jti`
252
308
  // (sid) and the server can revoke individual sessions (logout, compromise,
253
309
  // password-change). When unwired the framework issues plain stateless JWTs.
@@ -598,13 +654,17 @@ export function createAuthRoutes(
598
654
  const data = result.data as
599
655
  | { kind: "auth-session"; session: SessionUser }
600
656
  | { kind: "mfa-challenge"; challengeToken: string }
601
- | { kind: "mfa-setup-required" };
657
+ | { kind: "mfa-setup-required"; preauthSetupToken: string };
602
658
 
603
659
  if (data.kind === "mfa-setup-required") {
604
660
  // No session, no challenge — the client must show an
605
661
  // enrollment-required message. No rate-limit reset (same reasoning
606
662
  // as the mfa-challenge branch below).
607
- return c.json({ isSuccess: true, mfaSetupRequired: true });
663
+ return c.json({
664
+ isSuccess: true,
665
+ mfaSetupRequired: true,
666
+ preauthSetupToken: data.preauthSetupToken,
667
+ });
608
668
  }
609
669
 
610
670
  if (data.kind === "mfa-challenge") {
@@ -705,6 +765,126 @@ export function createAuthRoutes(
705
765
  });
706
766
  }
707
767
 
768
+ // POST /auth/mfa/preauth-enable-start — lets a user blocked at login by
769
+ // MFA enforcement (mfa-setup-required) generate a TOTP secret + QR
770
+ // without a session, using the preauthSetupToken login.write.ts issued.
771
+ // No JWT is minted here (unlike /auth/login and /auth/mfa/verify) — the
772
+ // handler's response carries a secret-bearing setupToken that a later
773
+ // pre-auth confirm step (the same shape auth-mfa's own enable-confirm
774
+ // consumes) verifies. No dedicated rate-limiter: see
775
+ // AuthRoutesConfig.mfaPreauthEnableStartHandler's doc comment for why
776
+ // the generic L2 /api/auth/* limiter is enough here.
777
+ if (config.mfaPreauthEnableStartHandler) {
778
+ const mfaPreauthEnableStartQn = config.mfaPreauthEnableStartHandler;
779
+ const statusMap = config.mfaPreauthEnableStartErrorStatusMap ?? {};
780
+
781
+ api.post(Routes.authMfaPreauthEnableStart, async (c) => {
782
+ const raw = await c.req.json().catch(() => null);
783
+ const parsed = MfaPreauthEnableStartBody.safeParse(raw);
784
+ if (!parsed.success) {
785
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
786
+ }
787
+ const body = parsed.data;
788
+
789
+ const result = await dispatcher.write(mfaPreauthEnableStartQn, body, GUEST_USER);
790
+
791
+ if (!result.isSuccess) {
792
+ // @cast-boundary error-details — KumikoError.details shape is per-error
793
+ const reason =
794
+ (result.error.details as { reason?: string } | undefined)?.reason ?? result.error.code;
795
+ // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
796
+ const status = (statusMap[reason] ?? result.error.httpStatus) as
797
+ | 400
798
+ | 401
799
+ | 403
800
+ | 422
801
+ | 429
802
+ | 500;
803
+ return c.json({ isSuccess: false, error: result.error }, status);
804
+ }
805
+
806
+ // @cast-boundary engine-payload — generic dispatcher.write result for
807
+ // the preauth-enable-start handler
808
+ const data = result.data as {
809
+ setupToken: string;
810
+ otpauthUri: string;
811
+ recoveryCodes: readonly string[];
812
+ };
813
+
814
+ return c.json({ isSuccess: true, ...data });
815
+ });
816
+ }
817
+
818
+ // POST /auth/mfa/preauth-confirm — completes both the enrollment started
819
+ // by preauth-enable-start AND the login mfa-setup-required blocked.
820
+ // Mirrors /auth/mfa/verify structurally (public, GUEST_USER dispatch,
821
+ // mintSessionAndRespond on success) with its OWN rate limiter
822
+ // (mfaPreauthConfirmRateLimit) — same reasoning as mfaVerifyRateLimit.
823
+ // Per-account brute-force protection (capping wrong-code guesses against
824
+ // one still-valid setupToken) is owned by the handler itself — see
825
+ // AuthRoutesConfig.mfaPreauthConfirmHandler's doc comment.
826
+ if (config.mfaPreauthConfirmHandler) {
827
+ const mfaPreauthConfirmQn = config.mfaPreauthConfirmHandler;
828
+ const statusMap = config.mfaPreauthConfirmErrorStatusMap ?? {};
829
+ const rateLimiter =
830
+ config.mfaPreauthConfirmRateLimit === null
831
+ ? null
832
+ : (config.mfaPreauthConfirmRateLimit ?? createInMemoryLoginRateLimiter());
833
+
834
+ api.post(Routes.authMfaPreauthConfirm, async (c) => {
835
+ const raw = await c.req.json().catch(() => null);
836
+ const parsed = MfaPreauthConfirmBody.safeParse(raw);
837
+ if (!parsed.success) {
838
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
839
+ }
840
+ const body = parsed.data;
841
+
842
+ const clientIp =
843
+ c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
844
+ c.req.header("x-real-ip") ??
845
+ "unknown";
846
+
847
+ if (rateLimiter) {
848
+ const allowed = await rateLimiter.check(clientIp);
849
+ if (!allowed) {
850
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
851
+ }
852
+ }
853
+
854
+ const result = await dispatcher.write(mfaPreauthConfirmQn, body, GUEST_USER);
855
+
856
+ if (!result.isSuccess) {
857
+ // @cast-boundary error-details — KumikoError.details shape is per-error
858
+ const reason =
859
+ (result.error.details as { reason?: string } | undefined)?.reason ?? result.error.code;
860
+ // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
861
+ const status = (statusMap[reason] ?? result.error.httpStatus) as
862
+ | 400
863
+ | 401
864
+ | 403
865
+ | 422
866
+ | 429
867
+ | 500;
868
+ return c.json({ isSuccess: false, error: result.error }, status);
869
+ }
870
+
871
+ // @cast-boundary engine-payload — generic dispatcher.write result for mfa-preauth-confirm handler
872
+ const data = result.data as { kind: "mfa-preauth-confirm-success"; session: SessionUser };
873
+
874
+ const token = await mintSessionAndRespond(c, data.session);
875
+
876
+ if (rateLimiter) {
877
+ await rateLimiter.reset(clientIp);
878
+ }
879
+
880
+ return c.json({
881
+ isSuccess: true,
882
+ token,
883
+ user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
884
+ });
885
+ });
886
+ }
887
+
708
888
  // POST /auth/request-password-reset + /auth/reset-password — public.
709
889
  // Silent-success on request (no enumeration), typed failure on confirm.
710
890
  // Rate-limit covered via config.rateLimit.auth (Sprint G.5 L2, /auth/*).
package/src/api/index.ts CHANGED
@@ -2,6 +2,7 @@ export type { SetTenantCookieOptions } from "./anonymous-cookie";
2
2
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie";
3
3
  export type {
4
4
  AnonymousAccessConfig,
5
+ AnonymousAccessResolved,
5
6
  AuthMiddlewareOptions,
6
7
  AuthSessionChecker,
7
8
  AuthSessionStatus,
package/src/api/server.ts CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  import type { SearchAdapter } from "../search/types";
48
48
  import { assertUnreachable, generateId } from "../utils";
49
49
  import { PUBLIC_API_PATHS } from "./api-constants";
50
- import { type AnonymousAccessConfig, authMiddleware, getUser } from "./auth-middleware";
50
+ import { type AnonymousAccessResolved, authMiddleware, getUser } from "./auth-middleware";
51
51
  import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
52
52
  import { csrfMiddleware } from "./csrf-middleware";
53
53
  import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
@@ -201,9 +201,10 @@ export type ServerOptions = {
201
201
  instanceId?: string;
202
202
  // Opt-in: serve unauthenticated requests on handlers that allow
203
203
  // roles=["anonymous"]. When omitted, every /api/* request still requires
204
- // a valid JWT (status quo). See AnonymousAccessConfig for the resolution
205
- // chain (header cookie resolver → defaultTenantId).
206
- anonymousAccess?: AnonymousAccessConfig;
204
+ // a valid JWT (status quo). App-facing config is AnonymousAccessConfig
205
+ // (defaultTenantId only); run{Prod,Dev}App merge auth-foundation tenant
206
+ // providers into AnonymousAccessResolved before calling buildServer.
207
+ anonymousAccess?: AnonymousAccessResolved;
207
208
  };
208
209
 
209
210
  export type KumikoServer = {
@@ -0,0 +1,175 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { EntityTableMeta } from "../entity-table-meta";
6
+ import { snapshotFromMetas } from "../migrate-generator";
7
+ import { diffReplayAgainstSnapshot, replayMigrationsDir } from "../replay-migration-sql";
8
+
9
+ function tmpMigrationsDir(): string {
10
+ return mkdtempSync(join(tmpdir(), "replay-migration-sql-"));
11
+ }
12
+
13
+ function write(dir: string, filename: string, sql: string): void {
14
+ writeFileSync(join(dir, filename), sql);
15
+ }
16
+
17
+ function meta(tableName: string, columns: EntityTableMeta["columns"]): EntityTableMeta {
18
+ return { tableName, source: "managed", indexes: [], columns };
19
+ }
20
+
21
+ describe("replayMigrationsDir", () => {
22
+ test("reconstructs table+column shape from CREATE TABLE", () => {
23
+ const dir = tmpMigrationsDir();
24
+ try {
25
+ write(
26
+ dir,
27
+ "0001_init.sql",
28
+ `CREATE TABLE IF NOT EXISTS "read_accounts" (
29
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
30
+ "tenant_id" uuid NOT NULL,
31
+ "stripe_customer_id" text
32
+ );
33
+ CREATE INDEX IF NOT EXISTS "read_accounts_tenant_id_idx" ON "read_accounts" ("tenant_id");`,
34
+ );
35
+ const replayed = replayMigrationsDir(dir);
36
+ expect([...replayed.keys()]).toEqual(["read_accounts"]);
37
+ expect([...(replayed.get("read_accounts")?.columns ?? [])].sort()).toEqual([
38
+ "id",
39
+ "stripe_customer_id",
40
+ "tenant_id",
41
+ ]);
42
+ } finally {
43
+ rmSync(dir, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ test("ADD COLUMN extends an already-created table across files", () => {
48
+ const dir = tmpMigrationsDir();
49
+ try {
50
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
51
+ write(dir, "0002_add-col.sql", `ALTER TABLE "read_a" ADD COLUMN "title" text;`);
52
+ const replayed = replayMigrationsDir(dir);
53
+ expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual(["id", "title"]);
54
+ } finally {
55
+ rmSync(dir, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test("commented-out destructive DROP TABLE is not replayed", () => {
60
+ const dir = tmpMigrationsDir();
61
+ try {
62
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
63
+ write(
64
+ dir,
65
+ "0002_drop.sql",
66
+ `-- DESTRUCTIVE: DROP TABLE IF EXISTS "read_a"; -- uncomment + ensure backup`,
67
+ );
68
+ const replayed = replayMigrationsDir(dir);
69
+ expect([...replayed.keys()]).toEqual(["read_a"]);
70
+ } finally {
71
+ rmSync(dir, { recursive: true, force: true });
72
+ }
73
+ });
74
+ });
75
+
76
+ describe("diffReplayAgainstSnapshot", () => {
77
+ test("clean: replayed schema matches snapshot exactly", () => {
78
+ const dir = tmpMigrationsDir();
79
+ try {
80
+ write(
81
+ dir,
82
+ "0001_init.sql",
83
+ `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY, "title" text);`,
84
+ );
85
+ const snapshot = snapshotFromMetas([
86
+ meta("read_a", [
87
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
88
+ { name: "title", pgType: "text", notNull: false },
89
+ ]),
90
+ ]);
91
+ const mismatches = diffReplayAgainstSnapshot(replayMigrationsDir(dir), snapshot);
92
+ expect(mismatches).toEqual([]);
93
+ } finally {
94
+ rmSync(dir, { recursive: true, force: true });
95
+ }
96
+ });
97
+
98
+ // Reproduces the kumiko-studio 0016 incident: the snapshot correctly
99
+ // records store_api_tokens, but the migration file that's supposed to
100
+ // create it is an accidental copy of an earlier file (creates read_accounts
101
+ // again instead) — CI must fail loud instead of shipping a table that only
102
+ // ever exists on paper.
103
+ test("misgenerated migration: snapshot expects a table no file actually creates", () => {
104
+ const dir = tmpMigrationsDir();
105
+ try {
106
+ write(
107
+ dir,
108
+ "0001_account-entity.sql",
109
+ `CREATE TABLE IF NOT EXISTS "read_accounts" ("id" uuid PRIMARY KEY, "stripe_customer_id" text);`,
110
+ );
111
+ write(
112
+ dir,
113
+ "0002_add-personal-access-tokens.sql",
114
+ // bug: this should create store_api_tokens, but it's a copy of 0001
115
+ `CREATE TABLE IF NOT EXISTS "read_accounts" ("id" uuid PRIMARY KEY, "stripe_customer_id" text);`,
116
+ );
117
+ const snapshot = snapshotFromMetas([
118
+ meta("read_accounts", [
119
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
120
+ { name: "stripe_customer_id", pgType: "text", notNull: false },
121
+ ]),
122
+ meta("store_api_tokens", [
123
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
124
+ { name: "token_hash", pgType: "text", notNull: true },
125
+ ]),
126
+ ]);
127
+ const mismatches = diffReplayAgainstSnapshot(replayMigrationsDir(dir), snapshot);
128
+ expect(mismatches).toEqual([
129
+ {
130
+ tableName: "store_api_tokens",
131
+ kind: "missing-table",
132
+ detail: 'snapshot expects "store_api_tokens" but no migration file creates it',
133
+ },
134
+ ]);
135
+ } finally {
136
+ rmSync(dir, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ test("column drift: migration creates a table with the wrong columns", () => {
141
+ const dir = tmpMigrationsDir();
142
+ try {
143
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
144
+ const snapshot = snapshotFromMetas([
145
+ meta("read_a", [
146
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
147
+ { name: "title", pgType: "text", notNull: false },
148
+ ]),
149
+ ]);
150
+ const mismatches = diffReplayAgainstSnapshot(replayMigrationsDir(dir), snapshot);
151
+ expect(mismatches).toEqual([
152
+ { tableName: "read_a", kind: "column-drift", detail: "missing columns: title" },
153
+ ]);
154
+ } finally {
155
+ rmSync(dir, { recursive: true, force: true });
156
+ }
157
+ });
158
+
159
+ test("unexpected table: migrations create a table the snapshot doesn't know about", () => {
160
+ const dir = tmpMigrationsDir();
161
+ try {
162
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "orphan" ("id" uuid PRIMARY KEY);`);
163
+ const mismatches = diffReplayAgainstSnapshot(replayMigrationsDir(dir), snapshotFromMetas([]));
164
+ expect(mismatches).toEqual([
165
+ {
166
+ tableName: "orphan",
167
+ kind: "unexpected-table",
168
+ detail: 'migrations create "orphan" but .snapshot.json has no entry for it',
169
+ },
170
+ ]);
171
+ } finally {
172
+ rmSync(dir, { recursive: true, force: true });
173
+ }
174
+ });
175
+ });
package/src/db/index.ts CHANGED
@@ -119,6 +119,13 @@ export {
119
119
  } from "./rebuild-marker";
120
120
  export { seedReferenceData } from "./reference-data";
121
121
  export { renderTableDdl, renderTablesDdl } from "./render-ddl";
122
+ export {
123
+ diffReplayAgainstSnapshot,
124
+ type ReplayedSchema,
125
+ type ReplayedTable,
126
+ type ReplayMismatch,
127
+ replayMigrationsDir,
128
+ } from "./replay-migration-sql";
122
129
  export { tableExists } from "./schema-inspection";
123
130
  export {
124
131
  buildBaseColumns,
@@ -0,0 +1,148 @@
1
+ // Build-time, DB-free replay: reads the checked-in `kumiko/migrations/*.sql`
2
+ // files in sequence order and reconstructs the table/column shape they
3
+ // actually produce — then that gets diffed against `.snapshot.json`.
4
+ //
5
+ // Catches the class of bug where a migration file's *content* silently
6
+ // drifts from what its filename/snapshot-entry claims (e.g. a copy-paste
7
+ // from an earlier migration): `kumiko schema validate`'s other checks only
8
+ // compare ENTITY_METAS ↔ snapshot, never the committed SQL bytes against
9
+ // either. Reuses `loadMigrationsFromDir`'s statement-splitting so the replay
10
+ // sees exactly what the real runner would execute.
11
+
12
+ import type { Snapshot } from "./migrate-generator";
13
+ import { loadMigrationsFromDir } from "./migrate-runner";
14
+
15
+ export type ReplayedTable = {
16
+ readonly columns: ReadonlySet<string>;
17
+ };
18
+
19
+ export type ReplayedSchema = ReadonlyMap<string, ReplayedTable>;
20
+
21
+ // Splits a parenthesized column-list body on top-level commas — depth-aware
22
+ // so commas inside `numeric(10,2)` or `DEFAULT gen_random_uuid()` don't
23
+ // fracture a column definition.
24
+ function splitTopLevel(body: string): readonly string[] {
25
+ const parts: string[] = [];
26
+ let depth = 0;
27
+ let current = "";
28
+ for (const ch of body) {
29
+ if (ch === "(") depth++;
30
+ if (ch === ")") depth--;
31
+ if (ch === "," && depth === 0) {
32
+ parts.push(current);
33
+ current = "";
34
+ } else {
35
+ current += ch;
36
+ }
37
+ }
38
+ if (current.trim().length > 0) parts.push(current);
39
+ return parts;
40
+ }
41
+
42
+ function parseColumnNames(body: string): Set<string> {
43
+ const columns = new Set<string>();
44
+ for (const part of splitTopLevel(body)) {
45
+ const trimmed = part.trim();
46
+ if (/^CONSTRAINT\b/i.test(trimmed)) continue; // composite-PK line, not a column
47
+ const match = trimmed.match(/^"([^"]+)"/);
48
+ if (match?.[1] !== undefined) columns.add(match[1]);
49
+ }
50
+ return columns;
51
+ }
52
+
53
+ function applyStatement(schema: Map<string, { columns: Set<string> }>, statement: string): void {
54
+ const create = statement.match(
55
+ /^CREATE TABLE\s+(?:IF NOT EXISTS\s+)?"([^"]+)"\s*\(([\s\S]*)\);?\s*$/i,
56
+ );
57
+ const createTableName = create?.[1];
58
+ const createBody = create?.[2];
59
+
60
+ const addColumn = statement.match(/^ALTER TABLE\s+"([^"]+)"\s+ADD COLUMN\s+"([^"]+)"/i);
61
+ const addColumnTable = addColumn?.[1];
62
+ const addColumnName = addColumn?.[2];
63
+
64
+ const dropTable = statement.match(/^DROP TABLE\s+(?:IF EXISTS\s+)?"([^"]+)"/i);
65
+ const dropTableName = dropTable?.[1];
66
+
67
+ const dropColumn = statement.match(/^ALTER TABLE\s+"([^"]+)"\s+DROP COLUMN\s+"([^"]+)"/i);
68
+ const dropColumnTable = dropColumn?.[1];
69
+ const dropColumnName = dropColumn?.[2];
70
+
71
+ if (createTableName !== undefined && createBody !== undefined) {
72
+ schema.set(createTableName, { columns: parseColumnNames(createBody) });
73
+ } else if (addColumnTable !== undefined && addColumnName !== undefined) {
74
+ const table = schema.get(addColumnTable);
75
+ if (table) table.columns.add(addColumnName);
76
+ else schema.set(addColumnTable, { columns: new Set([addColumnName]) });
77
+ } else if (dropTableName !== undefined) {
78
+ schema.delete(dropTableName);
79
+ } else if (dropColumnTable !== undefined && dropColumnName !== undefined) {
80
+ schema.get(dropColumnTable)?.columns.delete(dropColumnName);
81
+ }
82
+ // else: CREATE INDEX, ALTER COLUMN (TYPE/DEFAULT/NOT NULL) and everything
83
+ // else don't change the table/column shape this replay tracks.
84
+ }
85
+
86
+ // Reads `<migrationsDir>/*.sql` in sequence order and replays every
87
+ // CREATE/ALTER/DROP TABLE statement to reconstruct the resulting schema.
88
+ export function replayMigrationsDir(migrationsDir: string): ReplayedSchema {
89
+ const schema = new Map<string, { columns: Set<string> }>();
90
+ for (const migration of loadMigrationsFromDir(migrationsDir)) {
91
+ for (const statement of migration.statements) applyStatement(schema, statement);
92
+ }
93
+ return schema;
94
+ }
95
+
96
+ export type ReplayMismatch = {
97
+ readonly tableName: string;
98
+ readonly kind: "missing-table" | "unexpected-table" | "column-drift";
99
+ readonly detail: string;
100
+ };
101
+
102
+ // Compares what the migration files actually produce (`replayed`) against
103
+ // what `.snapshot.json` claims (`snapshot`) — the check that would have
104
+ // caught kumiko-studio's 0016 misgeneration (snapshot correct, SQL wrong).
105
+ export function diffReplayAgainstSnapshot(
106
+ replayed: ReplayedSchema,
107
+ snapshot: Snapshot,
108
+ ): readonly ReplayMismatch[] {
109
+ const mismatches: ReplayMismatch[] = [];
110
+ const snapshotTableNames = new Set(snapshot.tables.map((t) => t.tableName));
111
+
112
+ for (const meta of snapshot.tables) {
113
+ const table = replayed.get(meta.tableName);
114
+ if (!table) {
115
+ mismatches.push({
116
+ tableName: meta.tableName,
117
+ kind: "missing-table",
118
+ detail: `snapshot expects "${meta.tableName}" but no migration file creates it`,
119
+ });
120
+ continue;
121
+ }
122
+ const expected = new Set(meta.columns.map((c) => c.name));
123
+ const missing = [...expected].filter((c) => !table.columns.has(c));
124
+ const extra = [...table.columns].filter((c) => !expected.has(c));
125
+ if (missing.length > 0 || extra.length > 0) {
126
+ const parts: string[] = [];
127
+ if (missing.length > 0) parts.push(`missing columns: ${missing.join(", ")}`);
128
+ if (extra.length > 0) parts.push(`unexpected columns: ${extra.join(", ")}`);
129
+ mismatches.push({
130
+ tableName: meta.tableName,
131
+ kind: "column-drift",
132
+ detail: parts.join("; "),
133
+ });
134
+ }
135
+ }
136
+
137
+ for (const tableName of replayed.keys()) {
138
+ if (!snapshotTableNames.has(tableName)) {
139
+ mismatches.push({
140
+ tableName,
141
+ kind: "unexpected-table",
142
+ detail: `migrations create "${tableName}" but .snapshot.json has no entry for it`,
143
+ });
144
+ }
145
+ }
146
+
147
+ return mismatches;
148
+ }
package/src/schema-cli.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  baselineMigrations,
15
15
  createDbConnection,
16
16
  type DbConnection,
17
+ diffReplayAgainstSnapshot,
17
18
  fetchAppliedMigrations,
18
19
  generateMigration,
19
20
  loadMigrationsFromDir,
@@ -21,6 +22,7 @@ import {
21
22
  readRebuildMarker,
22
23
  rebuildTablesFromDiff,
23
24
  type renderTablesDdl,
25
+ replayMigrationsDir,
24
26
  runMigrationsFromDir,
25
27
  tableExists,
26
28
  writeRebuildMarker,
@@ -198,11 +200,18 @@ export async function runSchemaCli(
198
200
 
199
201
  case "validate": {
200
202
  // Static, DB-free boot-blocking checks for CI — catches "this won't boot"
201
- // before deploy. Two layers, no database:
203
+ // before deploy. Three layers, no database:
202
204
  // 1. schema drift: would `generate` write a migration? (= an entity was
203
205
  // added/changed but never generated → missing table → prod 500)
204
206
  // 2. boot validity: validateBoot over the composed FEATURES (QN/screen/
205
207
  // nav/role refs). Runs only if kumiko/schema.ts exports FEATURES.
208
+ // 3. migration-content drift: replay every committed *.sql file and
209
+ // diff the result against .snapshot.json — catches a migration
210
+ // file whose SQL body doesn't match what its own snapshot entry
211
+ // claims (e.g. an accidental copy-paste from an earlier file).
212
+ // Layer 1 alone misses this: it only compares ENTITY_METAS to the
213
+ // snapshot, never the snapshot to the SQL that's supposed to have
214
+ // produced it.
206
215
  // The DB-level gate (assertKumikoSchemaCurrent) stays at boot/deploy.
207
216
  if (!existsSync(schemaFile)) {
208
217
  out.err(` ${schemaFile} fehlt.`);
@@ -262,6 +271,28 @@ export async function runSchemaCli(
262
271
  );
263
272
  }
264
273
 
274
+ // 3. Migration-content drift — replay the committed *.sql files and
275
+ // diff the reconstructed schema against .snapshot.json.
276
+ const committedSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
277
+ if (existsSync(migrationsDir) && committedSnapshot !== null) {
278
+ const replayed = replayMigrationsDir(migrationsDir);
279
+ const mismatches = diffReplayAgainstSnapshot(replayed, committedSnapshot);
280
+ if (mismatches.length === 0) {
281
+ out.log(" ✓ migrations: committed SQL matches .snapshot.json");
282
+ } else {
283
+ ok = false;
284
+ out.err(
285
+ " ✗ migration-content drift: committed *.sql files don't produce .snapshot.json.",
286
+ );
287
+ for (const m of mismatches) {
288
+ out.err(` ${m.tableName} (${m.kind}): ${m.detail}`);
289
+ }
290
+ out.err(
291
+ " Fix: a migration file's body doesn't match what it (or the snapshot) claims — hand-fix the file, or ship a corrective migration if it's already applied in prod.",
292
+ );
293
+ }
294
+ }
295
+
265
296
  return ok ? 0 : 1;
266
297
  }
267
298
 
@@ -1,4 +1,5 @@
1
1
  import type { Hono } from "hono";
2
+ import type { SessionCreator } from "../api/auth-routes";
2
3
  import type { JwtHelper } from "../api/jwt";
3
4
  import type { SessionUser } from "../engine/types";
4
5
 
@@ -98,9 +99,25 @@ export type RequestHelper = {
98
99
  ) => Promise<Response>;
99
100
  };
100
101
 
101
- export function createRequestHelper(app: Hono, jwt: JwtHelper): RequestHelper {
102
+ export type RequestHelperOptions = {
103
+ // When sessionChecker is wired (sessions feature), JWTs without jti are
104
+ // rejected as no_sid. Seed/test helpers that only jwt.sign(user) need a
105
+ // live sid — create one via the same sessionCreator login uses (#1372).
106
+ readonly sessionCreator?: SessionCreator;
107
+ };
108
+
109
+ export function createRequestHelper(
110
+ app: Hono,
111
+ jwt: JwtHelper,
112
+ options: RequestHelperOptions = {},
113
+ ): RequestHelper {
102
114
  async function authHeader(user: SessionUser): Promise<Record<string, string>> {
103
- const token = await jwt.sign(user);
115
+ let forJwt = user;
116
+ if (options.sessionCreator && !user.sid) {
117
+ const sid = await options.sessionCreator(user, { ip: "test", userAgent: "request-helper" });
118
+ forJwt = { ...user, sid };
119
+ }
120
+ const token = await jwt.sign(forJwt);
104
121
  return { Authorization: `Bearer ${token}` };
105
122
  }
106
123
 
@@ -124,6 +124,16 @@ export type TestStackOptions = {
124
124
  sseBroker: import("../api/sse-broker").SseBroker;
125
125
  redis: import("ioredis").default;
126
126
  }) => import("../api/server").ServerOptions["anonymousAccess"]);
127
+ /** Optional post-factory enricher (e.g. merge auth-foundation tenant
128
+ * providers). Keeps framework free of a bundled-features dependency. */
129
+ enrichAnonymousAccess?: (
130
+ base: import("../api/server").ServerOptions["anonymousAccess"] | undefined,
131
+ deps: {
132
+ registry: Registry;
133
+ // biome-ignore lint/suspicious/noExplicitAny: cross-provider connection
134
+ db: any;
135
+ },
136
+ ) => Promise<import("../api/server").ServerOptions["anonymousAccess"] | undefined>;
127
137
  /** Opt-in JobRunner wired into ctx.jobRunner and merged into
128
138
  * dispatcherOptions so event-triggered jobs enqueue on commit — mirrors
129
139
  * the prod entrypoint's `buildJobRunnerWithHook`. Unlike prod (which
@@ -382,19 +392,24 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
382
392
  : {}),
383
393
  ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
384
394
  ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
385
- ...(options.anonymousAccess
386
- ? {
387
- anonymousAccess:
388
- typeof options.anonymousAccess === "function"
389
- ? options.anonymousAccess({
390
- registry,
391
- db: testDb.db,
392
- sseBroker,
393
- redis: testRedis.redis,
394
- })
395
- : options.anonymousAccess,
396
- }
397
- : {}),
395
+ ...(await (async () => {
396
+ const baseAnon =
397
+ typeof options.anonymousAccess === "function"
398
+ ? options.anonymousAccess({
399
+ registry,
400
+ db: testDb.db,
401
+ sseBroker,
402
+ redis: testRedis.redis,
403
+ })
404
+ : options.anonymousAccess;
405
+ const resolvedAnon = options.enrichAnonymousAccess
406
+ ? await options.enrichAnonymousAccess(baseAnon, {
407
+ registry,
408
+ db: testDb.db,
409
+ })
410
+ : baseAnon;
411
+ return resolvedAnon ? { anonymousAccess: resolvedAnon } : {};
412
+ })()),
398
413
  });
399
414
 
400
415
  const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
@@ -406,7 +421,11 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
406
421
  // timer loop call start() again (idempotent) after setup.
407
422
  if (eventDispatcher) await eventDispatcher.ensureRegistered();
408
423
 
409
- const http = createRequestHelper(server.app, server.jwt);
424
+ const http = createRequestHelper(server.app, server.jwt, {
425
+ ...(options.authConfig?.sessionCreator !== undefined && {
426
+ sessionCreator: options.authConfig.sessionCreator,
427
+ }),
428
+ });
410
429
 
411
430
  return {
412
431
  app: server.app,