@cosmicdrift/kumiko-framework 0.161.0 → 0.163.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.161.0",
3
+ "version": "0.163.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.161.0",
185
+ "@cosmicdrift/kumiko-types": "0.163.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.161.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.163.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}`,
@@ -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.
@@ -709,6 +765,126 @@ export function createAuthRoutes(
709
765
  });
710
766
  }
711
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
+
712
888
  // POST /auth/request-password-reset + /auth/reset-password — public.
713
889
  // Silent-success on request (no enumeration), typed failure on confirm.
714
890
  // Rate-limit covered via config.rateLimit.auth (Sprint G.5 L2, /auth/*).
@@ -0,0 +1,207 @@
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
+ // Hand-edited migrations legitimately add "IF NOT EXISTS"/"IF EXISTS" to
60
+ // ADD/DROP COLUMN (the generator itself never emits it, but app authors
61
+ // are explicitly allowed to hand-edit before committing — see the header
62
+ // comment every generated migration carries).
63
+ test("ADD COLUMN IF NOT EXISTS (hand-edited) still extends the table", () => {
64
+ const dir = tmpMigrationsDir();
65
+ try {
66
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
67
+ write(dir, "0002_add-col.sql", `ALTER TABLE "read_a" ADD COLUMN IF NOT EXISTS "title" text;`);
68
+ const replayed = replayMigrationsDir(dir);
69
+ expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual(["id", "title"]);
70
+ } finally {
71
+ rmSync(dir, { recursive: true, force: true });
72
+ }
73
+ });
74
+
75
+ test("DROP COLUMN IF EXISTS (hand-edited) still removes the column", () => {
76
+ const dir = tmpMigrationsDir();
77
+ try {
78
+ write(
79
+ dir,
80
+ "0001_init.sql",
81
+ `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY, "title" text);`,
82
+ );
83
+ write(dir, "0002_drop-col.sql", `ALTER TABLE "read_a" DROP COLUMN IF EXISTS "title";`);
84
+ const replayed = replayMigrationsDir(dir);
85
+ expect([...(replayed.get("read_a")?.columns ?? [])]).toEqual(["id"]);
86
+ } finally {
87
+ rmSync(dir, { recursive: true, force: true });
88
+ }
89
+ });
90
+
91
+ test("commented-out destructive DROP TABLE is not replayed", () => {
92
+ const dir = tmpMigrationsDir();
93
+ try {
94
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
95
+ write(
96
+ dir,
97
+ "0002_drop.sql",
98
+ `-- DESTRUCTIVE: DROP TABLE IF EXISTS "read_a"; -- uncomment + ensure backup`,
99
+ );
100
+ const replayed = replayMigrationsDir(dir);
101
+ expect([...replayed.keys()]).toEqual(["read_a"]);
102
+ } finally {
103
+ rmSync(dir, { recursive: true, force: true });
104
+ }
105
+ });
106
+ });
107
+
108
+ describe("diffReplayAgainstSnapshot", () => {
109
+ test("clean: replayed schema matches snapshot exactly", () => {
110
+ const dir = tmpMigrationsDir();
111
+ try {
112
+ write(
113
+ dir,
114
+ "0001_init.sql",
115
+ `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY, "title" text);`,
116
+ );
117
+ const snapshot = snapshotFromMetas([
118
+ meta("read_a", [
119
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
120
+ { name: "title", pgType: "text", notNull: false },
121
+ ]),
122
+ ]);
123
+ const mismatches = diffReplayAgainstSnapshot(replayMigrationsDir(dir), snapshot);
124
+ expect(mismatches).toEqual([]);
125
+ } finally {
126
+ rmSync(dir, { recursive: true, force: true });
127
+ }
128
+ });
129
+
130
+ // Reproduces the kumiko-studio 0016 incident: the snapshot correctly
131
+ // records store_api_tokens, but the migration file that's supposed to
132
+ // create it is an accidental copy of an earlier file (creates read_accounts
133
+ // again instead) — CI must fail loud instead of shipping a table that only
134
+ // ever exists on paper.
135
+ test("misgenerated migration: snapshot expects a table no file actually creates", () => {
136
+ const dir = tmpMigrationsDir();
137
+ try {
138
+ write(
139
+ dir,
140
+ "0001_account-entity.sql",
141
+ `CREATE TABLE IF NOT EXISTS "read_accounts" ("id" uuid PRIMARY KEY, "stripe_customer_id" text);`,
142
+ );
143
+ write(
144
+ dir,
145
+ "0002_add-personal-access-tokens.sql",
146
+ // bug: this should create store_api_tokens, but it's a copy of 0001
147
+ `CREATE TABLE IF NOT EXISTS "read_accounts" ("id" uuid PRIMARY KEY, "stripe_customer_id" text);`,
148
+ );
149
+ const snapshot = snapshotFromMetas([
150
+ meta("read_accounts", [
151
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
152
+ { name: "stripe_customer_id", pgType: "text", notNull: false },
153
+ ]),
154
+ meta("store_api_tokens", [
155
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
156
+ { name: "token_hash", pgType: "text", notNull: true },
157
+ ]),
158
+ ]);
159
+ const mismatches = diffReplayAgainstSnapshot(replayMigrationsDir(dir), snapshot);
160
+ expect(mismatches).toEqual([
161
+ {
162
+ tableName: "store_api_tokens",
163
+ kind: "missing-table",
164
+ detail: 'snapshot expects "store_api_tokens" but no migration file creates it',
165
+ },
166
+ ]);
167
+ } finally {
168
+ rmSync(dir, { recursive: true, force: true });
169
+ }
170
+ });
171
+
172
+ test("column drift: migration creates a table with the wrong columns", () => {
173
+ const dir = tmpMigrationsDir();
174
+ try {
175
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
176
+ const snapshot = snapshotFromMetas([
177
+ meta("read_a", [
178
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
179
+ { name: "title", pgType: "text", notNull: false },
180
+ ]),
181
+ ]);
182
+ const mismatches = diffReplayAgainstSnapshot(replayMigrationsDir(dir), snapshot);
183
+ expect(mismatches).toEqual([
184
+ { tableName: "read_a", kind: "column-drift", detail: "missing columns: title" },
185
+ ]);
186
+ } finally {
187
+ rmSync(dir, { recursive: true, force: true });
188
+ }
189
+ });
190
+
191
+ test("unexpected table: migrations create a table the snapshot doesn't know about", () => {
192
+ const dir = tmpMigrationsDir();
193
+ try {
194
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "orphan" ("id" uuid PRIMARY KEY);`);
195
+ const mismatches = diffReplayAgainstSnapshot(replayMigrationsDir(dir), snapshotFromMetas([]));
196
+ expect(mismatches).toEqual([
197
+ {
198
+ tableName: "orphan",
199
+ kind: "unexpected-table",
200
+ detail: 'migrations create "orphan" but .snapshot.json has no entry for it',
201
+ },
202
+ ]);
203
+ } finally {
204
+ rmSync(dir, { recursive: true, force: true });
205
+ }
206
+ });
207
+ });
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,152 @@
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(
61
+ /^ALTER TABLE\s+"([^"]+)"\s+ADD COLUMN\s+(?:IF NOT EXISTS\s+)?"([^"]+)"/i,
62
+ );
63
+ const addColumnTable = addColumn?.[1];
64
+ const addColumnName = addColumn?.[2];
65
+
66
+ const dropTable = statement.match(/^DROP TABLE\s+(?:IF EXISTS\s+)?"([^"]+)"/i);
67
+ const dropTableName = dropTable?.[1];
68
+
69
+ const dropColumn = statement.match(
70
+ /^ALTER TABLE\s+"([^"]+)"\s+DROP COLUMN\s+(?:IF EXISTS\s+)?"([^"]+)"/i,
71
+ );
72
+ const dropColumnTable = dropColumn?.[1];
73
+ const dropColumnName = dropColumn?.[2];
74
+
75
+ if (createTableName !== undefined && createBody !== undefined) {
76
+ schema.set(createTableName, { columns: parseColumnNames(createBody) });
77
+ } else if (addColumnTable !== undefined && addColumnName !== undefined) {
78
+ const table = schema.get(addColumnTable);
79
+ if (table) table.columns.add(addColumnName);
80
+ else schema.set(addColumnTable, { columns: new Set([addColumnName]) });
81
+ } else if (dropTableName !== undefined) {
82
+ schema.delete(dropTableName);
83
+ } else if (dropColumnTable !== undefined && dropColumnName !== undefined) {
84
+ schema.get(dropColumnTable)?.columns.delete(dropColumnName);
85
+ }
86
+ // else: CREATE INDEX, ALTER COLUMN (TYPE/DEFAULT/NOT NULL) and everything
87
+ // else don't change the table/column shape this replay tracks.
88
+ }
89
+
90
+ // Reads `<migrationsDir>/*.sql` in sequence order and replays every
91
+ // CREATE/ALTER/DROP TABLE statement to reconstruct the resulting schema.
92
+ export function replayMigrationsDir(migrationsDir: string): ReplayedSchema {
93
+ const schema = new Map<string, { columns: Set<string> }>();
94
+ for (const migration of loadMigrationsFromDir(migrationsDir)) {
95
+ for (const statement of migration.statements) applyStatement(schema, statement);
96
+ }
97
+ return schema;
98
+ }
99
+
100
+ export type ReplayMismatch = {
101
+ readonly tableName: string;
102
+ readonly kind: "missing-table" | "unexpected-table" | "column-drift";
103
+ readonly detail: string;
104
+ };
105
+
106
+ // Compares what the migration files actually produce (`replayed`) against
107
+ // what `.snapshot.json` claims (`snapshot`) — the check that would have
108
+ // caught kumiko-studio's 0016 misgeneration (snapshot correct, SQL wrong).
109
+ export function diffReplayAgainstSnapshot(
110
+ replayed: ReplayedSchema,
111
+ snapshot: Snapshot,
112
+ ): readonly ReplayMismatch[] {
113
+ const mismatches: ReplayMismatch[] = [];
114
+ const snapshotTableNames = new Set(snapshot.tables.map((t) => t.tableName));
115
+
116
+ for (const meta of snapshot.tables) {
117
+ const table = replayed.get(meta.tableName);
118
+ if (!table) {
119
+ mismatches.push({
120
+ tableName: meta.tableName,
121
+ kind: "missing-table",
122
+ detail: `snapshot expects "${meta.tableName}" but no migration file creates it`,
123
+ });
124
+ continue;
125
+ }
126
+ const expected = new Set(meta.columns.map((c) => c.name));
127
+ const missing = [...expected].filter((c) => !table.columns.has(c));
128
+ const extra = [...table.columns].filter((c) => !expected.has(c));
129
+ if (missing.length > 0 || extra.length > 0) {
130
+ const parts: string[] = [];
131
+ if (missing.length > 0) parts.push(`missing columns: ${missing.join(", ")}`);
132
+ if (extra.length > 0) parts.push(`unexpected columns: ${extra.join(", ")}`);
133
+ mismatches.push({
134
+ tableName: meta.tableName,
135
+ kind: "column-drift",
136
+ detail: parts.join("; "),
137
+ });
138
+ }
139
+ }
140
+
141
+ for (const tableName of replayed.keys()) {
142
+ if (!snapshotTableNames.has(tableName)) {
143
+ mismatches.push({
144
+ tableName,
145
+ kind: "unexpected-table",
146
+ detail: `migrations create "${tableName}" but .snapshot.json has no entry for it`,
147
+ });
148
+ }
149
+ }
150
+
151
+ return mismatches;
152
+ }
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