@cosmicdrift/kumiko-framework 0.48.1 → 0.50.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.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/src/api/__tests__/origin-middleware.integration.test.ts +132 -0
  3. package/src/api/__tests__/origin-middleware.test.ts +191 -0
  4. package/src/api/anonymous-cookie.ts +1 -0
  5. package/src/api/api-constants.ts +9 -0
  6. package/src/api/auth-middleware.ts +2 -0
  7. package/src/api/auth-routes.ts +29 -4
  8. package/src/api/csrf-middleware.ts +1 -4
  9. package/src/api/origin-middleware.ts +98 -0
  10. package/src/api/server.ts +20 -0
  11. package/src/bun-db/__tests__/_helpers.ts +1 -0
  12. package/src/bun-db/query.ts +1 -0
  13. package/src/db/__tests__/migrate-generator.test.ts +11 -0
  14. package/src/db/collect-table-metas.ts +2 -1
  15. package/src/db/dialect.ts +3 -1
  16. package/src/db/migrate-generator.ts +6 -2
  17. package/src/db/queries/event-store.ts +1 -0
  18. package/src/engine/__tests__/boot-validator.test.ts +23 -0
  19. package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +118 -0
  20. package/src/engine/__tests__/build-config-feature-schema.test.ts +174 -0
  21. package/src/engine/__tests__/config-helpers.test.ts +57 -0
  22. package/src/engine/boot-validator/config-deps.ts +26 -0
  23. package/src/engine/boot-validator/index.ts +2 -0
  24. package/src/engine/build-app-schema.ts +45 -0
  25. package/src/engine/build-config-feature-schema.ts +228 -0
  26. package/src/engine/config-helpers.ts +25 -0
  27. package/src/engine/define-handler.ts +1 -0
  28. package/src/engine/entity-handlers.ts +7 -1
  29. package/src/engine/feature-manifest.ts +1 -7
  30. package/src/engine/index.ts +8 -0
  31. package/src/engine/pipeline.ts +1 -0
  32. package/src/engine/qualified-name.ts +1 -0
  33. package/src/engine/state-machine.ts +1 -0
  34. package/src/engine/steps/compute.ts +1 -1
  35. package/src/engine/steps/return.ts +1 -0
  36. package/src/engine/types/config.ts +35 -0
  37. package/src/engine/types/index.ts +2 -0
  38. package/src/engine/types/screen.ts +13 -0
  39. package/src/env/index.ts +1 -0
  40. package/src/errors/write-error-info.ts +2 -0
  41. package/src/es-ops/__tests__/context.integration.test.ts +33 -24
  42. package/src/es-ops/__tests__/runner.integration.test.ts +130 -86
  43. package/src/es-ops/context.ts +6 -4
  44. package/src/event-store/event-store.ts +3 -0
  45. package/src/files/file-handle.ts +1 -1
  46. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +62 -1
  47. package/src/migrations/index.ts +1 -0
  48. package/src/migrations/pending-rebuilds.ts +48 -9
  49. package/src/pipeline/dispatcher.ts +5 -5
  50. package/src/pipeline/lifecycle-pipeline.ts +4 -4
  51. package/src/stack/table-helpers.ts +1 -0
  52. package/src/time/tz-context.ts +3 -3
  53. package/src/utils/compare.ts +10 -0
  54. package/src/utils/ids.ts +1 -0
  55. package/src/utils/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.48.1",
3
+ "version": "0.50.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>",
@@ -0,0 +1,132 @@
1
+ // origin-middleware wired into the real buildServer pipeline. Proves that the
2
+ // registration fires when authConfig.allowedOrigins is set, that the guard sits
3
+ // BEFORE the CSRF guard (a disallowed cross-site POST surfaces as
4
+ // `origin_not_allowed`, not `csrf_token_mismatch`), that cookie-vs-bearer
5
+ // transport detection works end-to-end, and that the explicit opt-out disables
6
+ // the guard without breaking boot. Cookie-auth is forged with a minted JWT — no
7
+ // full login flow needed to exercise the guard. Passing requests hit the
8
+ // dispatcher and 404 on the unknown query, which positively proves no guard
9
+ // rejected them (guards return 403).
10
+
11
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
13
+ import { AUTH_COOKIE_NAME, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "../auth-middleware";
14
+
15
+ const ALLOWED = "https://admin.example.eu";
16
+ const DISALLOWED = "https://tenant.example.eu";
17
+ const QUERY_BODY = { type: "noop:query:noop", payload: {} };
18
+
19
+ async function errorCode(res: Response): Promise<string | undefined> {
20
+ const body = (await res.json().catch(() => ({}))) as { error?: { code?: string } };
21
+ return body.error?.code;
22
+ }
23
+
24
+ describe("origin-middleware (integration)", () => {
25
+ let stack: TestStack;
26
+ let authCookie: string;
27
+
28
+ beforeAll(async () => {
29
+ stack = await setupTestStack({
30
+ features: [],
31
+ authConfig: {
32
+ // Never dispatched here — present only so options.auth is wired and the
33
+ // Origin guard registers. switch-tenant is the only consumer.
34
+ membershipQuery: "tenant:query:memberships",
35
+ allowedOrigins: [ALLOWED],
36
+ cookieDomain: "example.eu",
37
+ },
38
+ });
39
+ const token = await stack.jwt.sign(TestUsers.user);
40
+ authCookie = `${AUTH_COOKIE_NAME}=${token}`;
41
+ });
42
+
43
+ afterAll(async () => {
44
+ await stack.cleanup();
45
+ });
46
+
47
+ test("disallowed origin + simple text/plain POST → 403 origin_not_allowed (before CSRF)", async () => {
48
+ const res = await stack.http.raw("POST", "/api/query", QUERY_BODY, {
49
+ Cookie: authCookie,
50
+ Origin: DISALLOWED,
51
+ "Content-Type": "text/plain",
52
+ });
53
+ expect(res.status).toBe(403);
54
+ // No CSRF token sent — proves the Origin guard runs FIRST (else this would
55
+ // be csrf_token_mismatch).
56
+ expect(await errorCode(res)).toBe("origin_not_allowed");
57
+ });
58
+
59
+ test("allowed origin (no CSRF token) → falls through to the CSRF guard", async () => {
60
+ const res = await stack.http.raw("POST", "/api/query", QUERY_BODY, {
61
+ Cookie: authCookie,
62
+ Origin: ALLOWED,
63
+ });
64
+ expect(res.status).toBe(403);
65
+ expect(await errorCode(res)).toBe("csrf_token_mismatch");
66
+ });
67
+
68
+ test("no Origin + valid CSRF token → reaches dispatcher (Safari same-origin POST)", async () => {
69
+ const csrf = "csrf-fixed-integration-token";
70
+ const res = await stack.http.raw("POST", "/api/query", QUERY_BODY, {
71
+ Cookie: `${authCookie}; ${CSRF_COOKIE_NAME}=${csrf}`,
72
+ [CSRF_HEADER_NAME]: csrf,
73
+ });
74
+ // Both guards passed → the dispatcher 404s the unknown query. A positive
75
+ // assertion proves no guard (403) rejected the request.
76
+ expect(res.status).toBe(404);
77
+ expect(await errorCode(res)).toBe("not_found");
78
+ });
79
+
80
+ test("no Origin + Sec-Fetch-Site: cross-site → 403 origin_not_allowed", async () => {
81
+ const res = await stack.http.raw("POST", "/api/query", QUERY_BODY, {
82
+ Cookie: authCookie,
83
+ "Sec-Fetch-Site": "cross-site",
84
+ });
85
+ expect(res.status).toBe(403);
86
+ expect(await errorCode(res)).toBe("origin_not_allowed");
87
+ });
88
+
89
+ test("bearer transport + disallowed origin → skips both guards, reaches dispatcher", async () => {
90
+ const token = await stack.jwt.sign(TestUsers.user);
91
+ const res = await stack.http.raw("POST", "/api/query", QUERY_BODY, {
92
+ Authorization: `Bearer ${token}`,
93
+ Origin: DISALLOWED,
94
+ });
95
+ expect(res.status).toBe(404);
96
+ expect(await errorCode(res)).toBe("not_found");
97
+ });
98
+ });
99
+
100
+ describe("origin-middleware opt-out (unsafeSkipOriginCheck)", () => {
101
+ let stack: TestStack;
102
+ let authCookie: string;
103
+
104
+ beforeAll(async () => {
105
+ // cookieDomain set WITHOUT allowedOrigins would normally fail-closed; the
106
+ // explicit opt-out must let it boot AND leave the guard unregistered.
107
+ stack = await setupTestStack({
108
+ features: [],
109
+ authConfig: {
110
+ membershipQuery: "tenant:query:memberships",
111
+ cookieDomain: "example.eu",
112
+ unsafeSkipOriginCheck: true,
113
+ },
114
+ });
115
+ const token = await stack.jwt.sign(TestUsers.user);
116
+ authCookie = `${AUTH_COOKIE_NAME}=${token}`;
117
+ });
118
+
119
+ afterAll(async () => {
120
+ await stack.cleanup();
121
+ });
122
+
123
+ test("guard not registered → disallowed origin falls through to CSRF, not origin-blocked", async () => {
124
+ const res = await stack.http.raw("POST", "/api/query", QUERY_BODY, {
125
+ Cookie: authCookie,
126
+ Origin: DISALLOWED,
127
+ });
128
+ expect(res.status).toBe(403);
129
+ // CSRF fires (no token) — NOT the origin guard. Proves the opt-out disabled it.
130
+ expect(await errorCode(res)).toBe("csrf_token_mismatch");
131
+ });
132
+ });
@@ -0,0 +1,191 @@
1
+ // origin-middleware: server-side Origin-allowlist guard layered behind
2
+ // authMiddleware. Covers the production-relevant paths: cookie + state-
3
+ // changing (allowed/disallowed/simple-request/opaque, all four methods),
4
+ // cookie + safe method, bearer transport, the no-Origin Sec-Fetch-Site
5
+ // fallback, and the fail-closed boot check.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { Hono } from "hono";
9
+ import { TestUsers } from "../../stack";
10
+ import { AUTH_COOKIE_NAME, authMiddleware } from "../auth-middleware";
11
+ import { createJwtHelper } from "../jwt";
12
+ import {
13
+ assertOriginGuardConfig,
14
+ isOriginAllowed,
15
+ normalizeOrigin,
16
+ originMiddleware,
17
+ } from "../origin-middleware";
18
+
19
+ const JWT_SECRET = "origin-middleware-test-secret-min-32-characters-long";
20
+ const ALLOWED = "https://admin.example.eu";
21
+ const DISALLOWED = "https://tenant.example.eu";
22
+
23
+ async function buildApp(): Promise<{ app: Hono; token: string }> {
24
+ const jwt = createJwtHelper(JWT_SECRET);
25
+ const token = await jwt.sign(TestUsers.user);
26
+ const app = new Hono();
27
+ app.use("/api/*", authMiddleware(jwt));
28
+ app.use("/api/*", originMiddleware([ALLOWED]));
29
+ app.get("/api/ping", (c) => c.json({ ok: true }));
30
+ app.post("/api/write", (c) => c.json({ ok: true }));
31
+ return { app, token };
32
+ }
33
+
34
+ describe("normalizeOrigin", () => {
35
+ test("lowercases and strips trailing slash + whitespace", () => {
36
+ expect(normalizeOrigin("HTTPS://Admin.Example.EU/")).toBe("https://admin.example.eu");
37
+ expect(normalizeOrigin(" https://admin.example.eu ")).toBe("https://admin.example.eu");
38
+ expect(normalizeOrigin("https://admin.example.eu")).toBe("https://admin.example.eu");
39
+ });
40
+ });
41
+
42
+ describe("isOriginAllowed", () => {
43
+ const allowlist = new Set([normalizeOrigin(ALLOWED)]);
44
+ test("matches normalized entry regardless of case/trailing slash", () => {
45
+ expect(isOriginAllowed("https://admin.example.eu", allowlist)).toBe(true);
46
+ expect(isOriginAllowed("HTTPS://ADMIN.EXAMPLE.EU/", allowlist)).toBe(true);
47
+ });
48
+ test("rejects a non-listed origin and the opaque 'null' origin", () => {
49
+ expect(isOriginAllowed(DISALLOWED, allowlist)).toBe(false);
50
+ expect(isOriginAllowed("null", allowlist)).toBe(false);
51
+ });
52
+ });
53
+
54
+ describe("assertOriginGuardConfig", () => {
55
+ test("throws when cookieDomain is set without allowedOrigins or opt-out", () => {
56
+ expect(() => assertOriginGuardConfig({ cookieDomain: "example.eu" })).toThrow(/allowedOrigins/);
57
+ });
58
+ test("throws when allowedOrigins is an empty array", () => {
59
+ expect(() =>
60
+ assertOriginGuardConfig({ cookieDomain: "example.eu", allowedOrigins: [] }),
61
+ ).toThrow();
62
+ });
63
+ test("passes when allowedOrigins is set", () => {
64
+ expect(() =>
65
+ assertOriginGuardConfig({ cookieDomain: "example.eu", allowedOrigins: [ALLOWED] }),
66
+ ).not.toThrow();
67
+ });
68
+ test("passes when explicitly opted out", () => {
69
+ expect(() =>
70
+ assertOriginGuardConfig({ cookieDomain: "example.eu", unsafeSkipOriginCheck: true }),
71
+ ).not.toThrow();
72
+ });
73
+ test("passes when no cookieDomain (host-only cookie) or no auth at all", () => {
74
+ expect(() => assertOriginGuardConfig({})).not.toThrow();
75
+ expect(() => assertOriginGuardConfig(undefined)).not.toThrow();
76
+ });
77
+ });
78
+
79
+ describe("originMiddleware", () => {
80
+ test("bearer transport skips the check even on a disallowed-origin POST", async () => {
81
+ const { app, token } = await buildApp();
82
+ const res = await app.request("/api/write", {
83
+ method: "POST",
84
+ headers: { Authorization: `Bearer ${token}`, Origin: DISALLOWED },
85
+ });
86
+ expect(res.status).toBe(200);
87
+ });
88
+
89
+ test("cookie transport + GET → no check (safe method)", async () => {
90
+ const { app, token } = await buildApp();
91
+ const res = await app.request("/api/ping", {
92
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: DISALLOWED },
93
+ });
94
+ expect(res.status).toBe(200);
95
+ });
96
+
97
+ test("cookie transport + POST + allowed origin → ok", async () => {
98
+ const { app, token } = await buildApp();
99
+ const res = await app.request("/api/write", {
100
+ method: "POST",
101
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: ALLOWED },
102
+ });
103
+ expect(res.status).toBe(200);
104
+ });
105
+
106
+ test("cookie transport + POST + disallowed origin → 403 origin_not_allowed", async () => {
107
+ const { app, token } = await buildApp();
108
+ const res = await app.request("/api/write", {
109
+ method: "POST",
110
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: DISALLOWED },
111
+ });
112
+ expect(res.status).toBe(403);
113
+ const body = (await res.json()) as { error: { code: string } };
114
+ expect(body.error.code).toBe("origin_not_allowed");
115
+ });
116
+
117
+ // The guard runs as /api/* middleware before routing, so a disallowed-origin
118
+ // request is rejected for every state-changing method even without a route.
119
+ test.each([
120
+ "PUT",
121
+ "PATCH",
122
+ "DELETE",
123
+ ])("cookie transport + %s + disallowed origin → 403 (every state-changing method)", async (method) => {
124
+ const { app, token } = await buildApp();
125
+ const res = await app.request("/api/write", {
126
+ method,
127
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: DISALLOWED },
128
+ });
129
+ expect(res.status).toBe(403);
130
+ expect(((await res.json()) as { error: { code: string } }).error.code).toBe(
131
+ "origin_not_allowed",
132
+ );
133
+ });
134
+
135
+ test("disallowed origin is blocked even as a simple text/plain request", async () => {
136
+ // The real vector: a `text/plain` POST skips the CORS preflight and reaches
137
+ // the server, where only the Origin check stands between it and the handler.
138
+ const { app, token } = await buildApp();
139
+ const res = await app.request("/api/write", {
140
+ method: "POST",
141
+ headers: {
142
+ Cookie: `${AUTH_COOKIE_NAME}=${token}`,
143
+ Origin: DISALLOWED,
144
+ "Content-Type": "text/plain",
145
+ },
146
+ body: "type=x",
147
+ });
148
+ expect(res.status).toBe(403);
149
+ });
150
+
151
+ test("cookie transport + POST + no Origin → passes (CSRF token is the next layer)", async () => {
152
+ const { app, token } = await buildApp();
153
+ const res = await app.request("/api/write", {
154
+ method: "POST",
155
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}` },
156
+ });
157
+ expect(res.status).toBe(200);
158
+ });
159
+
160
+ test("no Origin + Sec-Fetch-Site: same-origin → passes", async () => {
161
+ const { app, token } = await buildApp();
162
+ const res = await app.request("/api/write", {
163
+ method: "POST",
164
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, "Sec-Fetch-Site": "same-origin" },
165
+ });
166
+ expect(res.status).toBe(200);
167
+ });
168
+
169
+ // same-site is passed through by design (the CSRF token is the next layer);
170
+ // the realistic same-site XSS attack carries an Origin header and is rejected
171
+ // by the allowlist branch before this fallback is reached.
172
+ test("no Origin + Sec-Fetch-Site: same-site → passes (intentional, CSRF is next layer)", async () => {
173
+ const { app, token } = await buildApp();
174
+ const res = await app.request("/api/write", {
175
+ method: "POST",
176
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, "Sec-Fetch-Site": "same-site" },
177
+ });
178
+ expect(res.status).toBe(200);
179
+ });
180
+
181
+ test("no Origin + Sec-Fetch-Site: cross-site → 403", async () => {
182
+ const { app, token } = await buildApp();
183
+ const res = await app.request("/api/write", {
184
+ method: "POST",
185
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, "Sec-Fetch-Site": "cross-site" },
186
+ });
187
+ expect(res.status).toBe(403);
188
+ const body = (await res.json()) as { error: { code: string } };
189
+ expect(body.error.code).toBe("origin_not_allowed");
190
+ });
191
+ });
@@ -55,6 +55,7 @@ export function setTenantCookie(
55
55
  // Removes the kumiko_tenant cookie. Use on switch-tenant flows or when
56
56
  // the resolver no longer recognises the visitor's tenant — leaving a
57
57
  // stale cookie behind would keep them pointed at a deleted tenant.
58
+ // @wrapper-known semantic-alias
58
59
  export function deleteTenantCookie(c: Context): void {
59
60
  deleteCookie(c, TENANT_COOKIE_NAME, { path: "/" });
60
61
  }
@@ -53,6 +53,15 @@ export const PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
53
53
  `/api${Routes.version}`,
54
54
  ]);
55
55
 
56
+ // Methods that can mutate server state. GET/HEAD/OPTIONS are safe under
57
+ // CORS + SameSite-cookie semantics and skip the CSRF / Origin guards entirely.
58
+ export const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set([
59
+ "POST",
60
+ "PUT",
61
+ "PATCH",
62
+ "DELETE",
63
+ ]);
64
+
56
65
  // Tenant transports for unauthenticated callers on public endpoints. JWT
57
66
  // users carry tenantId in the signed token; anonymous callers must declare
58
67
  // the tenant out-of-band — header for SPA/mobile, cookie for browser-direct
@@ -109,6 +109,7 @@ type MiddlewareRejectCode =
109
109
  | "tenant_mismatch"
110
110
  | "invalid_tenant_format";
111
111
 
112
+ // @wrapper-known error-helper
112
113
  function middlewareReject(
113
114
  c: Context,
114
115
  opts: {
@@ -133,6 +134,7 @@ function middlewareReject(
133
134
  );
134
135
  }
135
136
 
137
+ // @wrapper-known error-helper
136
138
  function sessionInvalid(c: Context, reason: AuthSessionStatus | "no_sid"): Response {
137
139
  return middlewareReject(c, {
138
140
  code: "session_invalid",
@@ -257,8 +257,33 @@ export type AuthRoutesConfig = {
257
257
  // live on DIFFERENT subdomains (login on apex, app on admin.) — the
258
258
  // browser then sends the session to every subdomain. Careful: that
259
259
  // includes ALL subdomains (tenant pages, previews); widen the scope
260
- // only when the cross-subdomain session is actually required.
260
+ // only when the cross-subdomain session is actually required — and pair
261
+ // it with `allowedOrigins` so the server-side Origin guard stays on (a
262
+ // wide cookie makes the JS-readable csrf cookie reachable from every
263
+ // subdomain, weakening the double-submit defence).
261
264
  cookieDomain?: string;
265
+ // Origin-allowlist for the server-side CSRF-hardening guard (origin-
266
+ // middleware). When non-empty, every cookie-authenticated, state-changing
267
+ // /api request must carry an `Origin` header that exact-matches one of
268
+ // these entries (scheme+host+optional port, e.g. "https://example.eu", no
269
+ // trailing slash, no wildcards). Requests without an Origin fall back to
270
+ // Sec-Fetch-Site and then to the CSRF token — the guard is defense-in-
271
+ // depth, not a replacement.
272
+ //
273
+ // List ONLY the trusted entry hosts: the apex and the admin host. Do NOT
274
+ // list tenant/public subdomains — with a wide `cookieDomain` they share
275
+ // the session cookie, so an XSS there is exactly the threat this blocks.
276
+ // Empty/unset (default) disables the guard; required only when
277
+ // `cookieDomain` widens the cookie across subdomains.
278
+ allowedOrigins?: readonly string[];
279
+ // Explicit opt-out of the fail-closed Origin guard. When `cookieDomain` is
280
+ // set the framework REFUSES TO BOOT unless `allowedOrigins` is configured — a
281
+ // wide cookie without an Origin check is the unguarded-subdomain-XSS footgun.
282
+ // Set this true ONLY for a single-host deployment that shares no untrusted
283
+ // subdomains and genuinely needs the wide cookie anyway; you accept that any
284
+ // subdomain can then forge authenticated state-changing requests. Prefer
285
+ // setting `allowedOrigins`.
286
+ unsafeSkipOriginCheck?: boolean;
262
287
  };
263
288
 
264
289
  export type PasswordResetConfig = {
@@ -527,7 +552,7 @@ export function createAuthRoutes(
527
552
  successKind: "reset-requested",
528
553
  appBaseUrl: pr.appResetUrl,
529
554
  sendEmail: ({ email, url, expiresAt }) =>
530
- pr.sendResetEmail({ email, resetUrl: url, expiresAt }),
555
+ pr.sendResetEmail({ email, resetUrl: url, expiresAt }), // @wrapper-known semantic-alias
531
556
  });
532
557
  registerTokenConfirmRoute({
533
558
  api,
@@ -549,7 +574,7 @@ export function createAuthRoutes(
549
574
  successKind: "verification-requested",
550
575
  appBaseUrl: ev.appVerifyUrl,
551
576
  sendEmail: ({ email, url, expiresAt }) =>
552
- ev.sendVerificationEmail({ email, verificationUrl: url, expiresAt }),
577
+ ev.sendVerificationEmail({ email, verificationUrl: url, expiresAt }), // @wrapper-known semantic-alias
553
578
  });
554
579
  registerTokenConfirmRoute({
555
580
  api,
@@ -574,7 +599,7 @@ export function createAuthRoutes(
574
599
  successKind: "signup-requested",
575
600
  appBaseUrl: sg.appActivationUrl,
576
601
  sendEmail: ({ email, url, expiresAt }) =>
577
- sg.sendActivationEmail({ email, activationUrl: url, expiresAt }),
602
+ sg.sendActivationEmail({ email, activationUrl: url, expiresAt }), // @wrapper-known semantic-alias
578
603
  });
579
604
 
580
605
  api.post(Routes.authSignupConfirm, async (c) => {
@@ -1,12 +1,9 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import type { Context, Next } from "hono";
3
3
  import { getCookie } from "hono/cookie";
4
+ import { STATE_CHANGING_METHODS } from "./api-constants";
4
5
  import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME, getAuthTransport } from "./auth-middleware";
5
6
 
6
- // Methods that can mutate server state. GET/HEAD/OPTIONS are safe under
7
- // CORS + SameSite-cookie semantics and skip the CSRF check entirely.
8
- const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set(["POST", "PUT", "PATCH", "DELETE"]);
9
-
10
7
  // Constant-time byte compare. `a !== b` short-circuits at the first
11
8
  // differing byte and leaks the common prefix length to anyone who can
12
9
  // time requests — in principle exploitable against sufficiently small
@@ -0,0 +1,98 @@
1
+ import type { Context, Next } from "hono";
2
+ import { STATE_CHANGING_METHODS } from "./api-constants";
3
+ import { getAuthTransport } from "./auth-middleware";
4
+
5
+ // Canonical comparable form for an Origin / allowlist entry: lowercased, no
6
+ // trailing slash. Origin headers are scheme+host(+port) without a path, but
7
+ // config entries are hand-written and may carry a stray slash or mixed case.
8
+ export function normalizeOrigin(value: string): string {
9
+ return value.trim().toLowerCase().replace(/\/+$/, "");
10
+ }
11
+
12
+ // Exact-match against a pre-normalized allowlist. No wildcards by design:
13
+ // `*.example.eu` would re-admit the tenant subdomains this guard exists to
14
+ // shut out.
15
+ export function isOriginAllowed(origin: string, normalizedAllowlist: ReadonlySet<string>): boolean {
16
+ return normalizedAllowlist.has(normalizeOrigin(origin));
17
+ }
18
+
19
+ function rejectOrigin(c: Context): Response {
20
+ return c.json(
21
+ {
22
+ error: {
23
+ code: "origin_not_allowed",
24
+ httpStatus: 403,
25
+ message: "request origin is not allowed",
26
+ i18nKey: "auth.errors.originNotAllowed",
27
+ },
28
+ },
29
+ 403,
30
+ );
31
+ }
32
+
33
+ // Server-side Origin-allowlist guard — an additional CSRF-hardening layer on
34
+ // top of the double-submit token (csrf-middleware) for deployments that widen
35
+ // the auth cookie across subdomains via AuthRoutesConfig.cookieDomain. A wide
36
+ // cookie means an XSS on ANY subdomain (e.g. a tenant status page) can read
37
+ // the JS-readable kumiko_csrf cookie and forge an authenticated state-changing
38
+ // request. Pinning the request Origin to the apex + admin host (never tenant
39
+ // subdomains) closes that vector even for "simple requests" (text/plain or
40
+ // form-encoded) that skip the CORS preflight and reach the server.
41
+ //
42
+ // Runs AFTER authMiddleware (reads the authTransport flag) with the same scope
43
+ // as the CSRF guard: only cookie-authenticated, state-changing requests.
44
+ // Bearer-auth requests skip — browsers cannot set Authorization cross-origin,
45
+ // and native clients send no/foreign Origin, so guarding them would be a
46
+ // false-positive with no CSRF vector to defend.
47
+ export function originMiddleware(allowedOrigins: readonly string[]) {
48
+ const allowlist: ReadonlySet<string> = new Set(allowedOrigins.map(normalizeOrigin));
49
+ return async (c: Context, next: Next) => {
50
+ const transport = getAuthTransport(c);
51
+ if (transport !== "cookie") return next();
52
+ if (!STATE_CHANGING_METHODS.has(c.req.method)) return next();
53
+
54
+ const origin = c.req.header("origin");
55
+ if (origin !== undefined) {
56
+ if (isOriginAllowed(origin, allowlist)) return next();
57
+ return rejectOrigin(c);
58
+ }
59
+
60
+ // No Origin header — older browsers, and some same-origin POSTs in Safari.
61
+ // Fall back to the Fetch-Metadata Sec-Fetch-Site signal: only an explicit
62
+ // cross-site marker is blocked here (it's a relation, not an origin, so it
63
+ // can't be matched against the allowlist). Everything else (same-site,
64
+ // same-origin, none, absent) falls through to the CSRF token. Note: the
65
+ // CSRF token alone does NOT stop a same-site subdomain XSS — it can read
66
+ // the wide cookie — but that attack uses fetch/XHR, which always sends an
67
+ // Origin header and is already rejected by the allowlist branch above. The
68
+ // residual is only the no-Origin-yet-Sec-Fetch-Site combo, which no current
69
+ // browser emits for state-changing requests.
70
+ if (c.req.header("sec-fetch-site") === "cross-site") return rejectOrigin(c);
71
+
72
+ return next();
73
+ };
74
+ }
75
+
76
+ // Fail-closed boot check: a wide cookieDomain shares the JS-readable
77
+ // kumiko_csrf cookie across every subdomain, so a subdomain XSS can read it and
78
+ // defeat the double-submit CSRF check. Without an Origin allowlist there is no
79
+ // server-side barrier left — refuse to boot in that configuration unless the
80
+ // operator opts out explicitly. Called once from buildServer.
81
+ export function assertOriginGuardConfig(
82
+ auth:
83
+ | { cookieDomain?: string; allowedOrigins?: readonly string[]; unsafeSkipOriginCheck?: boolean }
84
+ | undefined,
85
+ ): void {
86
+ const widensCookieAcrossSubdomains = Boolean(auth?.cookieDomain);
87
+ const hasAllowlist = (auth?.allowedOrigins?.length ?? 0) > 0;
88
+ const optedOut = auth?.unsafeSkipOriginCheck === true;
89
+ if (widensCookieAcrossSubdomains && !hasAllowlist && !optedOut) {
90
+ throw new Error(
91
+ "[kumiko:boot] auth.cookieDomain widens the session cookie across subdomains, but " +
92
+ "auth.allowedOrigins is empty — the JS-readable kumiko_csrf cookie would be reachable " +
93
+ "from every subdomain (e.g. tenant pages) with no server-side Origin check, defeating " +
94
+ "CSRF protection. Set auth.allowedOrigins to the apex + admin host, or set " +
95
+ "auth.unsafeSkipOriginCheck: true to accept the risk explicitly.",
96
+ );
97
+ }
98
+ }
package/src/api/server.ts CHANGED
@@ -49,6 +49,7 @@ import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
49
49
  import { csrfMiddleware } from "./csrf-middleware";
50
50
  import { createJwtHelper, type JwtHelper } from "./jwt";
51
51
  import { observabilityMiddleware } from "./observability-middleware";
52
+ import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
52
53
  import { requestIdMiddleware } from "./request-id-middleware";
53
54
  import {
54
55
  DEFAULT_MAX_REQUEST_BYTES,
@@ -543,6 +544,24 @@ export function buildServer(options: ServerOptions): KumikoServer {
543
544
  return jwtGuard(c, next);
544
545
  });
545
546
 
547
+ // Origin-allowlist guard — additional CSRF-hardening for deployments that
548
+ // widen the auth cookie across subdomains (auth.cookieDomain). Registered
549
+ // only when allowedOrigins is non-empty, with the same /api/* + public-skip
550
+ // scope as the CSRF guard and BEFORE it, so a disallowed cross-site POST
551
+ // surfaces as `origin_not_allowed` rather than `csrf_token_mismatch`. Fails
552
+ // closed (assertOriginGuardConfig throws) when a wide cookieDomain is set
553
+ // without an allowlist and without an explicit opt-out — that config is the
554
+ // unguarded-subdomain-XSS footgun, not a warn-and-continue case.
555
+ assertOriginGuardConfig(options.auth);
556
+ const allowedOrigins = options.auth?.allowedOrigins;
557
+ if (allowedOrigins && allowedOrigins.length > 0) {
558
+ const originGuard = originMiddleware(allowedOrigins);
559
+ app.use("/api/*", async (c, next) => {
560
+ if (PUBLIC_API_PATHS.has(c.req.path)) return next();
561
+ return originGuard(c, next);
562
+ });
563
+ }
564
+
546
565
  // Double-submit CSRF guard — runs only on cookie-authenticated,
547
566
  // state-changing requests (POST/PUT/PATCH/DELETE). The guard reads the
548
567
  // authTransport flag set by authMiddleware, so public paths (no auth)
@@ -584,6 +603,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
584
603
  // Auth-Pfad wie ein echter HTTP-Call).
585
604
  for (const feature of options.registry.features.values()) {
586
605
  for (const route of Object.values(feature.httpRoutes)) {
606
+ // @wrapper-known semantic-alias
587
607
  const honoHandler = async (c: import("hono").Context): Promise<Response> =>
588
608
  route.handler(c, { app });
589
609
  switch (route.method) {
@@ -24,6 +24,7 @@ let dbInstance: { db: unknown; close: () => Promise<void> } | undefined;
24
24
  export async function getDb(): Promise<unknown> {
25
25
  return ensureDb();
26
26
  }
27
+ // @wrapper-known test-helper
27
28
  export async function ensureDb(): Promise<unknown> {
28
29
  if (!dbInstance) {
29
30
  dbInstance = await createConnection(DATABASE_URL, { maxConnections: 4 });
@@ -153,6 +153,7 @@ function tenantDbDelegate(db: unknown): TenantDbDelegate | undefined {
153
153
  // passing a TenantDb here would silently run unscoped against `.raw`, bypassing
154
154
  // the tenant filter. Fail loudly so the caller picks `ctx.db.<method>` (scoped)
155
155
  // or `ctx.db.raw` (explicit cross-tenant) on purpose.
156
+ // @wrapper-known semantic-alias
156
157
  function assertNotTenantScoped(db: unknown, fnName: string): void {
157
158
  if (tenantDbDelegate(db) !== undefined) {
158
159
  throw new Error(
@@ -29,6 +29,17 @@ describe("snapshotFromMetas", () => {
29
29
  expect(snap.tables.map((t) => t.tableName)).toEqual(["apples", "zebras"]);
30
30
  expect(snap.version).toBe(1);
31
31
  });
32
+
33
+ test("sorts by codepoint, not locale — deterministic across ICU locales (#367)", () => {
34
+ // localeCompare orders case-insensitively ("apple" < "Zebra"); codepoint
35
+ // puts uppercase (U+005A) before lowercase (U+0061) → "Zebra" < "apple".
36
+ // The snapshot JSON is byte-compared and the order carries into the
37
+ // generated migration SQL, so a revert to localeCompare would reorder the
38
+ // committed bytes depending on the runner's ICU locale. Fails the moment
39
+ // anyone swaps compareByCodepoint back to localeCompare.
40
+ const snap = snapshotFromMetas([meta("apple"), meta("Zebra")]);
41
+ expect(snap.tables.map((t) => t.tableName)).toEqual(["Zebra", "apple"]);
42
+ });
32
43
  });
33
44
 
34
45
  describe("diffSnapshots", () => {
@@ -7,6 +7,7 @@
7
7
  // crashte (#255).
8
8
 
9
9
  import type { FeatureDefinition } from "../engine/types";
10
+ import { compareByCodepoint } from "../utils";
10
11
  import {
11
12
  assertBackingTableSuperset,
12
13
  buildEntityTableMeta,
@@ -21,7 +22,7 @@ function canonicalColumnsKey(meta: EntityTableMeta): string {
21
22
  // (buildEntityTable ohne relations) trägt legitim weniger FK-Indexes
22
23
  // als das Entity-Meta derselben Tabelle.
23
24
  return [...meta.columns]
24
- .sort((a, b) => a.name.localeCompare(b.name))
25
+ .sort((a, b) => compareByCodepoint(a.name, b.name))
25
26
  .map(
26
27
  (c) =>
27
28
  `${c.name}|${c.pgType}|${c.notNull}|${c.defaultSql ?? ""}|${c.primaryKey ?? false}|${c.identity ?? false}|${c.bigintJsMode ?? ""}`,
package/src/db/dialect.ts CHANGED
@@ -337,10 +337,12 @@ function makeIndex(name: string, unique: boolean): IndexBuilder {
337
337
  };
338
338
  }
339
339
 
340
+ // @wrapper-known semantic-alias
340
341
  export function index(name: string): IndexBuilder {
341
342
  return makeIndex(name, false);
342
343
  }
343
344
 
345
+ // @wrapper-known semantic-alias
344
346
  export function uniqueIndex(name: string): IndexBuilder {
345
347
  return makeIndex(name, true);
346
348
  }
@@ -428,7 +430,7 @@ export function table<TCols extends ColumnMap>(
428
430
  const handle: ColumnHandle = {
429
431
  name: final.sqlName,
430
432
  pgType: final.pgType,
431
- getSQLType: () => pgTypeToSqlType(final.pgType),
433
+ getSQLType: () => pgTypeToSqlType(final.pgType), // @wrapper-known semantic-alias
432
434
  };
433
435
  handles[field] = handle;
434
436
  const meta: ColumnMeta = {