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