@cosmicdrift/kumiko-framework 0.161.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 +3 -3
- package/src/__tests__/schema-cli.integration.test.ts +21 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +205 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +186 -0
- package/src/api/api-constants.ts +4 -0
- package/src/api/auth-routes.ts +176 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +175 -0
- package/src/db/index.ts +7 -0
- package/src/db/replay-migration-sql.ts +148 -0
- package/src/schema-cli.ts +32 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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
|
+
});
|
package/src/api/api-constants.ts
CHANGED
|
@@ -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}`,
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -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,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.
|
|
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
|
|