@cosmicdrift/kumiko-framework 0.299.0 → 0.305.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 (66) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/api.test.ts +3 -2
  3. package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
  4. package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
  5. package/src/api/__tests__/http-route-entry.integration.test.ts +114 -0
  6. package/src/api/__tests__/server-error-logging.test.ts +33 -0
  7. package/src/api/api-constants.ts +13 -0
  8. package/src/api/auth-routes.ts +53 -24
  9. package/src/api/extra-route.ts +33 -4
  10. package/src/api/index.ts +1 -0
  11. package/src/api/server.ts +79 -34
  12. package/src/changes.json +68 -0
  13. package/src/db/event-store-executor-write.ts +7 -0
  14. package/src/db/tenant-db.ts +50 -3
  15. package/src/engine/__tests__/http-route-anonymous-required.test.ts +43 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +13 -4
  17. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +127 -0
  18. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +77 -0
  19. package/src/engine/boot-validator/access-declarations.ts +58 -67
  20. package/src/engine/boot-validator/entity-handler.ts +20 -0
  21. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  22. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  23. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +14 -0
  24. package/src/engine/feature-ast/extractors/hooks.ts +3 -1
  25. package/src/engine/feature-ast/extractors/jobs-routes.ts +5 -2
  26. package/src/engine/feature-ast/patcher.ts +2 -2
  27. package/src/engine/feature-ast/patterns.ts +1 -1
  28. package/src/engine/feature-ast/render.ts +1 -1
  29. package/src/engine/feature-ui-extensions.ts +6 -0
  30. package/src/engine/index.ts +4 -0
  31. package/src/engine/membership-roles.ts +20 -4
  32. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  33. package/src/engine/pattern-library/mixed-schemas.ts +1 -0
  34. package/src/engine/personal-data-fields.ts +66 -0
  35. package/src/engine/registry-validate.ts +15 -0
  36. package/src/engine/registry.ts +2 -0
  37. package/src/engine/types/index.ts +4 -0
  38. package/src/env/__tests__/dry-run.test.ts +43 -3
  39. package/src/env/dry-run.ts +28 -15
  40. package/src/errors/__tests__/write-failures.test.ts +47 -4
  41. package/src/errors/i18n/de.yaml +12 -0
  42. package/src/errors/i18n/en.yaml +12 -0
  43. package/src/errors/reasons.ts +4 -0
  44. package/src/errors/write-error-info.ts +12 -3
  45. package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
  46. package/src/jobs/__tests__/jobs.integration.test.ts +35 -0
  47. package/src/jobs/job-runner.ts +19 -5
  48. package/src/observability/__tests__/metrics-wiring.test.ts +61 -0
  49. package/src/observability/index.ts +5 -0
  50. package/src/observability/metrics-wiring.ts +32 -0
  51. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
  52. package/src/pipeline/active-membership.ts +5 -1
  53. package/src/pipeline/dispatch-batch.ts +3 -0
  54. package/src/pipeline/dispatch-query.ts +16 -5
  55. package/src/pipeline/dispatch-shared.ts +12 -5
  56. package/src/pipeline/dispatch-stream.ts +7 -2
  57. package/src/pipeline/dispatch-write.ts +22 -5
  58. package/src/pipeline/dispatcher.ts +9 -2
  59. package/src/pipeline/member-reader.ts +3 -1
  60. package/src/pipeline/write-origin.ts +107 -0
  61. package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
  62. package/src/rate-limit/middleware.ts +3 -0
  63. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
  64. package/src/stack/test-stack.ts +5 -0
  65. package/src/testing/handler-context.ts +3 -1
  66. package/src/ui-types/index.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.299.0",
3
+ "version": "0.305.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>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.299.0",
202
- "@cosmicdrift/kumiko-types": "0.299.0",
201
+ "@cosmicdrift/kumiko-http": "0.305.0",
202
+ "@cosmicdrift/kumiko-types": "0.305.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.299.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.305.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -851,6 +851,7 @@ describe("feature-declared HTTP routes (r.httpRoute)", () => {
851
851
  r.httpRoute({
852
852
  method: "GET",
853
853
  path: "/api/forbidden",
854
+ anonymous: true,
854
855
  handler: (c) => c.text("nope"),
855
856
  });
856
857
  }),
@@ -860,8 +861,8 @@ describe("feature-declared HTTP routes (r.httpRoute)", () => {
860
861
  test("Boot-Validator: doppelte method+path-Combo wird abgelehnt", () => {
861
862
  expect(() =>
862
863
  defineFeature("dup", (r) => {
863
- r.httpRoute({ method: "GET", path: "/x", handler: (c) => c.text("a") });
864
- r.httpRoute({ method: "GET", path: "/x", handler: (c) => c.text("b") });
864
+ r.httpRoute({ method: "GET", path: "/x", anonymous: true, handler: (c) => c.text("a") });
865
+ r.httpRoute({ method: "GET", path: "/x", anonymous: true, handler: (c) => c.text("b") });
865
866
  }),
866
867
  ).toThrow(/already registered/);
867
868
  });
@@ -0,0 +1,38 @@
1
+ // Constructor invariants for ExtraRouteRejection's `retryAfterSeconds` option
2
+ // (kumiko-framework#3168): it only makes sense together with 503 and must
3
+ // render as a valid Retry-After delta-seconds header.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { ExtraRouteRejection } from "../extra-route";
7
+
8
+ describe("ExtraRouteRejection retryAfterSeconds invariants", () => {
9
+ test("retryAfterSeconds with a non-503 status throws RangeError", () => {
10
+ expect(
11
+ () => new ExtraRouteRejection(404, { error: "x" }, undefined, { retryAfterSeconds: 30 }),
12
+ ).toThrow(RangeError);
13
+ });
14
+
15
+ test("negative retryAfterSeconds throws RangeError", () => {
16
+ expect(
17
+ () => new ExtraRouteRejection(503, { error: "x" }, undefined, { retryAfterSeconds: -1 }),
18
+ ).toThrow(RangeError);
19
+ });
20
+
21
+ test("non-integer retryAfterSeconds throws RangeError", () => {
22
+ expect(
23
+ () => new ExtraRouteRejection(503, { error: "x" }, undefined, { retryAfterSeconds: 1.5 }),
24
+ ).toThrow(RangeError);
25
+ });
26
+
27
+ test("retryAfterSeconds: 0 with status 503 is accepted", () => {
28
+ const rejection = new ExtraRouteRejection(503, { error: "x" }, undefined, {
29
+ retryAfterSeconds: 0,
30
+ });
31
+ expect(rejection.retryAfterSeconds).toBe(0);
32
+ });
33
+
34
+ test("no options leaves retryAfterSeconds undefined", () => {
35
+ const rejection = new ExtraRouteRejection(503, { error: "x" });
36
+ expect(rejection.retryAfterSeconds).toBeUndefined();
37
+ });
38
+ });
@@ -461,6 +461,14 @@ describe("extraRoutes: entry:signature", () => {
461
461
  if (req.headers["x-force-404"] === "1") {
462
462
  throw new ExtraRouteRejection(404, { error: "unknown-provider" });
463
463
  }
464
+ if (req.headers["x-force-503"] === "retry") {
465
+ throw new ExtraRouteRejection(503, { error: "not-ready" }, undefined, {
466
+ retryAfterSeconds: 30,
467
+ });
468
+ }
469
+ if (req.headers["x-force-503"] === "plain") {
470
+ throw new ExtraRouteRejection(503, { error: "not-ready" });
471
+ }
464
472
  if (req.headers["x-hmac"] !== signHmac(req.rawBody)) {
465
473
  throw new Error("signature mismatch");
466
474
  }
@@ -536,6 +544,28 @@ describe("extraRoutes: entry:signature", () => {
536
544
  expect(await res.json()).toEqual({ error: "unknown-provider" });
537
545
  });
538
546
 
547
+ test("verify() throwing ExtraRouteRejection(503, body, options) surfaces the body and Retry-After header", async () => {
548
+ const res = await stack.app.request("/webhooks/probe", {
549
+ method: "POST",
550
+ headers: { "x-force-503": "retry", "content-type": "application/json" },
551
+ body: JSON.stringify({ note: "x" }),
552
+ });
553
+ expect(res.status).toBe(503);
554
+ expect(await res.json()).toEqual({ error: "not-ready" });
555
+ expect(res.headers.get("retry-after")).toBe("30");
556
+ });
557
+
558
+ test("verify() throwing ExtraRouteRejection(503, body) without options omits the Retry-After header", async () => {
559
+ const res = await stack.app.request("/webhooks/probe", {
560
+ method: "POST",
561
+ headers: { "x-force-503": "plain", "content-type": "application/json" },
562
+ body: JSON.stringify({ note: "x" }),
563
+ });
564
+ expect(res.status).toBe(503);
565
+ expect(await res.json()).toEqual({ error: "not-ready" });
566
+ expect(res.headers.get("retry-after")).toBeNull();
567
+ });
568
+
539
569
  test("mounted under /api/:provider — no session needed, honoPathToRegex matches the :param, rawBody arrives intact through /api/*", async () => {
540
570
  const rawBody = JSON.stringify({ event: "payment.succeeded" });
541
571
  const res = await stack.app.request("/api/webhooks/stripe", {
@@ -0,0 +1,114 @@
1
+ // kumiko-framework#2885 step 3: `HttpRouteDefinition.anonymous` now controls
2
+ // the mount, not just docs/boot-validation. `anonymous: false` must sit
3
+ // behind the SAME session-auth chain /api/* uses (no anonymous fallthrough,
4
+ // PAT rate limit, origin + CSRF guards) — a request without a session gets
5
+ // 401, never a synthesized anonymous user. `anonymous: true` stays public.
6
+ //
7
+ // anonymousAccess is deliberately wired on the test stack: without it, a
8
+ // wrongly-mounted `anonymous: false` route (using jwtGuard instead of
9
+ // sessionOnlyGuard) would ALSO 401 on a missing token, masking the bug the
10
+ // 401 test exists to catch.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { defineFeature } from "../../engine";
14
+ import type { TenantId } from "../../engine/types/identifiers";
15
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
16
+ import { AUTH_COOKIE_NAME, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, getUser } from "../auth-middleware";
17
+
18
+ const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
19
+
20
+ const entryFeature = defineFeature("http-route-entry", (r) => {
21
+ r.httpRoute({
22
+ method: "GET",
23
+ path: "/entry-public",
24
+ anonymous: true,
25
+ handler: async (c) => c.json({ ok: true }),
26
+ });
27
+ r.httpRoute({
28
+ method: "GET",
29
+ path: "/entry-private",
30
+ anonymous: false,
31
+ handler: async (c) => c.json({ id: getUser(c).id }),
32
+ });
33
+ r.httpRoute({
34
+ method: "POST",
35
+ path: "/entry-private",
36
+ anonymous: false,
37
+ handler: async (c) => c.json({ id: getUser(c).id }),
38
+ });
39
+ });
40
+
41
+ let stack: TestStack;
42
+
43
+ beforeAll(async () => {
44
+ stack = await setupTestStack({
45
+ features: [entryFeature],
46
+ anonymousAccess: { defaultTenantId: TENANT_ID },
47
+ });
48
+ });
49
+
50
+ afterAll(async () => {
51
+ await stack.cleanup();
52
+ });
53
+
54
+ describe("r.httpRoute anonymous:false — session auth chain", () => {
55
+ test("GET without a session → 401, no anonymous fallthrough", async () => {
56
+ const res = await stack.app.request("/entry-private");
57
+ expect(res.status).toBe(401);
58
+ const body = (await res.json()) as { error: { code: string } };
59
+ expect(body.error.code).toBe("missing_token");
60
+ });
61
+
62
+ // Hono answers HEAD through the GET route while c.req.method stays "HEAD";
63
+ // the guard chain must still run for it.
64
+ test("HEAD without a session → 401, not the GET handler unguarded", async () => {
65
+ const res = await stack.app.request("/entry-private", { method: "HEAD" });
66
+ expect(res.status).toBe(401);
67
+ });
68
+
69
+ test("GET with a real session → 200, handler sees the logged-in user, not anonymous", async () => {
70
+ const token = await stack.jwt.sign(TestUsers.user);
71
+ const res = await stack.app.request("/entry-private", {
72
+ headers: { Authorization: `Bearer ${token}` },
73
+ });
74
+ expect(res.status).toBe(200);
75
+ const body = (await res.json()) as { id: string };
76
+ expect(body.id).toBe(TestUsers.user.id);
77
+ expect(body.id).not.toBe("anonymous");
78
+ });
79
+
80
+ test("POST with cookie auth, no CSRF token → 403 csrf_token_mismatch", async () => {
81
+ const token = await stack.jwt.sign(TestUsers.user);
82
+ const res = await stack.app.request("/entry-private", {
83
+ method: "POST",
84
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}` },
85
+ });
86
+ expect(res.status).toBe(403);
87
+ const body = (await res.json()) as { error: { code: string } };
88
+ expect(body.error.code).toBe("csrf_token_mismatch");
89
+ });
90
+
91
+ test("POST with cookie auth + matching CSRF token → 200", async () => {
92
+ const token = await stack.jwt.sign(TestUsers.user);
93
+ const csrf = "csrf-fixed-http-route-entry-token";
94
+ const res = await stack.app.request("/entry-private", {
95
+ method: "POST",
96
+ headers: {
97
+ Cookie: `${AUTH_COOKIE_NAME}=${token}; ${CSRF_COOKIE_NAME}=${csrf}`,
98
+ [CSRF_HEADER_NAME]: csrf,
99
+ },
100
+ });
101
+ expect(res.status).toBe(200);
102
+ const body = (await res.json()) as { id: string };
103
+ expect(body.id).toBe(TestUsers.user.id);
104
+ });
105
+ });
106
+
107
+ describe("r.httpRoute anonymous:true — stays public", () => {
108
+ test("GET without a session → 200", async () => {
109
+ const res = await stack.app.request("/entry-public");
110
+ expect(res.status).toBe(200);
111
+ const body = (await res.json()) as { ok: boolean };
112
+ expect(body.ok).toBe(true);
113
+ });
114
+ });
@@ -27,6 +27,14 @@ const boomFeature = defineFeature("boom", (r) => {
27
27
  },
28
28
  openToAll,
29
29
  );
30
+ r.writeHandler(
31
+ "explode",
32
+ z.object({}),
33
+ async () => {
34
+ throw new Error("disk on fire during write");
35
+ },
36
+ openToAll,
37
+ );
30
38
  r.queryHandler(
31
39
  "decode",
32
40
  z.object({}),
@@ -140,6 +148,31 @@ describe("HTTP layer logs unexpected 5xx faults", () => {
140
148
  }
141
149
  });
142
150
 
151
+ test("a throwing write 500s AND its cause reaches the log (reraise keeps the cause)", async () => {
152
+ const calls: unknown[][] = [];
153
+ const spy = spyOn(console, "error").mockImplementation((...args) => {
154
+ calls.push(args);
155
+ });
156
+ try {
157
+ const res = await app.request("/api/write", {
158
+ method: "POST",
159
+ headers: await auth(),
160
+ body: JSON.stringify({ type: "boom:write:explode", payload: {} }),
161
+ });
162
+ expect(res.status).toBe(500);
163
+ const hit = calls.find(
164
+ (args) => typeof args[0] === "string" && args[0].includes("[api] handler failed"),
165
+ );
166
+ const data = hit?.[1];
167
+ expect(isRecord(data)).toBe(true);
168
+ if (!isRecord(data)) return;
169
+ expect(data["type"]).toBe("boom:write:explode");
170
+ expect(data["cause"]).toBe("disk on fire during write");
171
+ } finally {
172
+ spy.mockRestore();
173
+ }
174
+ });
175
+
143
176
  test("a 404 stays off the error level (it is a client outcome, not a server fault)", async () => {
144
177
  const { status, errors } = await queryWithCapturedWarnings("nope:query:nothing", {});
145
178
  expect(status).toBe(404);
@@ -101,6 +101,19 @@ export const BODY_LIMIT_OPT_OUT_PATHS: ReadonlySet<string> = new Set([
101
101
  `/api${Routes.files}`,
102
102
  ]);
103
103
 
104
+ // authEndpointRateLimit (L2) exceptions, exact method+path match, no prefix/glob.
105
+ // A forgotten entry here is safe (over-limited, not unlimited).
106
+ export const AUTH_RATE_LIMIT_EXEMPT_ROUTES: ReadonlyArray<{
107
+ readonly method: string;
108
+ readonly path: string;
109
+ }> = [{ method: "GET", path: `/api${Routes.authTenants}` }];
110
+
111
+ export function isAuthRateLimitExempt(method: string, path: string): boolean {
112
+ return AUTH_RATE_LIMIT_EXEMPT_ROUTES.some(
113
+ (route) => route.method === method && route.path === path,
114
+ );
115
+ }
116
+
104
117
  // Methods that can mutate server state. GET/HEAD/OPTIONS are safe under
105
118
  // CORS + SameSite-cookie semantics and skip the CSRF / Origin guards entirely.
106
119
  export const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set([
@@ -5,7 +5,7 @@ import { deleteCookie, setCookie } from "hono/cookie";
5
5
  import type Redis from "ioredis";
6
6
  import { z } from "zod";
7
7
  import { buildSessionRoles } from "../engine/membership-roles";
8
- import { createSystemUser } from "../engine/system-user";
8
+ import { createAnonymousUser, createSystemUser } from "../engine/system-user";
9
9
  import {
10
10
  type ActiveMembershipRejection,
11
11
  type ActiveMembershipResult,
@@ -197,15 +197,6 @@ type MembershipRow = {
197
197
  tenantKey?: string;
198
198
  };
199
199
 
200
- // Guest identity used for unauthenticated calls (e.g. login). The "all" role
201
- // lets framework access checks pass for handlers declared with roles: ["all"].
202
- // `id` is the zero-uuid so it flows through event-store columns cleanly.
203
- const GUEST_USER: SessionUser = {
204
- id: "00000000-0000-0000-0000-000000000000",
205
- tenantId: SYSTEM_TENANT_ID,
206
- roles: ["all"],
207
- };
208
-
209
200
  // Pluggable rate-limiter for POST /auth/login. Returning `false` blocks the
210
201
  // request with 429 before the login handler runs — use this to slow down
211
202
  // brute-force attempts. The framework ships a default in-memory impl; apps
@@ -780,7 +771,7 @@ export function createAuthRoutes(
780
771
  }
781
772
  }
782
773
 
783
- const result = await dispatcher.write(loginQn, body, GUEST_USER);
774
+ const result = await dispatcher.write(loginQn, body, createAnonymousUser(SYSTEM_TENANT_ID));
784
775
 
785
776
  if (!result.isSuccess) {
786
777
  // Feature-specific auth reason codes arrive via UnprocessableError.details.reason
@@ -845,7 +836,7 @@ export function createAuthRoutes(
845
836
  }
846
837
 
847
838
  // POST /auth/mfa/verify — completes a two-step login. Mirrors /auth/login
848
- // structurally (public, GUEST_USER dispatch, mintSessionAndRespond on
839
+ // structurally (public, anonymous identity dispatch, mintSessionAndRespond on
849
840
  // success) but with its OWN rate limiter (mfaVerifyRateLimit) — this route
850
841
  // never goes through a dispatcher write-handler's own rateLimit config,
851
842
  // so without this it would have NO rate limiting at all. Per-account
@@ -877,7 +868,11 @@ export function createAuthRoutes(
877
868
  }
878
869
  }
879
870
 
880
- const result = await dispatcher.write(mfaVerifyQn, body, GUEST_USER);
871
+ const result = await dispatcher.write(
872
+ mfaVerifyQn,
873
+ body,
874
+ createAnonymousUser(SYSTEM_TENANT_ID),
875
+ );
881
876
 
882
877
  if (!result.isSuccess) {
883
878
  // @cast-boundary error-details — KumikoError.details shape is per-error
@@ -954,7 +949,11 @@ export function createAuthRoutes(
954
949
  }
955
950
  }
956
951
 
957
- const result = await dispatcher.write(mfaPreauthEnableStartQn, body, GUEST_USER);
952
+ const result = await dispatcher.write(
953
+ mfaPreauthEnableStartQn,
954
+ body,
955
+ createAnonymousUser(SYSTEM_TENANT_ID),
956
+ );
958
957
 
959
958
  if (!result.isSuccess) {
960
959
  // @cast-boundary error-details — KumikoError.details shape is per-error
@@ -985,7 +984,7 @@ export function createAuthRoutes(
985
984
 
986
985
  // POST /auth/mfa/preauth-confirm — completes both the enrollment started
987
986
  // by preauth-enable-start AND the login mfa-setup-required blocked.
988
- // Mirrors /auth/mfa/verify structurally (public, GUEST_USER dispatch,
987
+ // Mirrors /auth/mfa/verify structurally (public, anonymous identity dispatch,
989
988
  // mintSessionAndRespond on success) with its OWN rate limiter
990
989
  // (mfaPreauthConfirmRateLimit) — same reasoning as mfaVerifyRateLimit.
991
990
  // Per-account brute-force protection (capping wrong-code guesses against
@@ -1016,7 +1015,11 @@ export function createAuthRoutes(
1016
1015
  }
1017
1016
  }
1018
1017
 
1019
- const result = await dispatcher.write(mfaPreauthConfirmQn, body, GUEST_USER);
1018
+ const result = await dispatcher.write(
1019
+ mfaPreauthConfirmQn,
1020
+ body,
1021
+ createAnonymousUser(SYSTEM_TENANT_ID),
1022
+ );
1020
1023
 
1021
1024
  if (!result.isSuccess) {
1022
1025
  // @cast-boundary error-details — KumikoError.details shape is per-error
@@ -1128,7 +1131,11 @@ export function createAuthRoutes(
1128
1131
  return c.json({ isSuccess: false, error: "invalid_body" }, 400);
1129
1132
  }
1130
1133
 
1131
- const result = await dispatcher.write(sg.confirmHandler, parsed.data, GUEST_USER);
1134
+ const result = await dispatcher.write(
1135
+ sg.confirmHandler,
1136
+ parsed.data,
1137
+ createAnonymousUser(SYSTEM_TENANT_ID),
1138
+ );
1132
1139
 
1133
1140
  if (!result.isSuccess) {
1134
1141
  // 422 für invalid_signup_token (handler-level UnprocessableError).
@@ -1210,7 +1217,11 @@ export function createAuthRoutes(
1210
1217
  if (!parsed.success) {
1211
1218
  return c.json({ isSuccess: false, error: "invalid_body" }, 400);
1212
1219
  }
1213
- const result = await dispatcher.write(inv.acceptWithLoginHandler, parsed.data, GUEST_USER);
1220
+ const result = await dispatcher.write(
1221
+ inv.acceptWithLoginHandler,
1222
+ parsed.data,
1223
+ createAnonymousUser(SYSTEM_TENANT_ID),
1224
+ );
1214
1225
  if (!result.isSuccess) {
1215
1226
  const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500; // @cast-boundary engine-payload
1216
1227
  return c.json({ isSuccess: false, error: result.error }, status);
@@ -1258,7 +1269,11 @@ export function createAuthRoutes(
1258
1269
  if (!parsed.success) {
1259
1270
  return c.json({ isSuccess: false, error: "invalid_body" }, 400);
1260
1271
  }
1261
- const result = await dispatcher.write(inv.signupCompleteHandler, parsed.data, GUEST_USER);
1272
+ const result = await dispatcher.write(
1273
+ inv.signupCompleteHandler,
1274
+ parsed.data,
1275
+ createAnonymousUser(SYSTEM_TENANT_ID),
1276
+ );
1262
1277
  if (!result.isSuccess) {
1263
1278
  const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500; // @cast-boundary engine-payload
1264
1279
  return c.json({ isSuccess: false, error: result.error }, status);
@@ -1457,10 +1472,20 @@ function registerTokenRequestRoute(opts: {
1457
1472
  if (!parsed.success) return c.json({ isSuccess: true });
1458
1473
 
1459
1474
  // The handler dispatches the magic-link mail via delivery before returning.
1460
- // Handler-level failures (only legitimate reason: misconfiguration) are
1461
- // silently swallowed observability logs capture them for ops so the
1462
- // response shape stays uniform for unknown vs. known emails.
1463
- await opts.dispatcher.write(opts.requestHandler, { email: parsed.data.email }, GUEST_USER);
1475
+ // Handler-level failures (misconfiguration, e.g. no RateLimitResolver) stay
1476
+ // out of the response so it is uniform for unknown vs. known emails, but
1477
+ // they are logged otherwise the mail silently never goes out. No email in
1478
+ // the log line (PII).
1479
+ const result = await opts.dispatcher.write(
1480
+ opts.requestHandler,
1481
+ { email: parsed.data.email },
1482
+ createAnonymousUser(SYSTEM_TENANT_ID),
1483
+ );
1484
+ if (!result.isSuccess) {
1485
+ console.error(
1486
+ `[kumiko] token request handler "${opts.requestHandler}" failed: ${result.error.code}`,
1487
+ );
1488
+ }
1464
1489
 
1465
1490
  return c.json({ isSuccess: true });
1466
1491
  });
@@ -1480,7 +1505,11 @@ function registerTokenConfirmRoute(opts: {
1480
1505
  if (!parsed.success) {
1481
1506
  return c.json({ isSuccess: false, error: "invalid_body" }, 400);
1482
1507
  }
1483
- const result = await opts.dispatcher.write(opts.confirmHandler, parsed.data, GUEST_USER);
1508
+ const result = await opts.dispatcher.write(
1509
+ opts.confirmHandler,
1510
+ parsed.data,
1511
+ createAnonymousUser(SYSTEM_TENANT_ID),
1512
+ );
1484
1513
  if (!result.isSuccess) {
1485
1514
  const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500; // @cast-boundary engine-payload
1486
1515
  return c.json({ isSuccess: false, error: result.error }, status);
@@ -128,19 +128,48 @@ export function signatureRoute<T>(def: SignatureExtraRoute<T>): ExtraRouteDefini
128
128
  return def as unknown as SignatureExtraRoute<unknown>;
129
129
  }
130
130
 
131
- export type ExtraRouteRejectionStatus = 400 | 401 | 403 | 404 | 500;
131
+ export type ExtraRouteRejectionStatus = 400 | 401 | 403 | 404 | 500 | 503;
132
+
133
+ export type ExtraRouteRejectionOptions = { readonly retryAfterSeconds?: number };
132
134
 
133
135
  /** Thrown by `verify()` to reject a signature route with a specific status +
134
136
  * JSON body. Any other throw from `verify()` is mapped to 401
135
- * `extra_route_signature_invalid` by the buildServer wrapper. */
137
+ * `extra_route_signature_invalid` by the buildServer wrapper.
138
+ *
139
+ * 503 signals "temporarily not ready" (e.g. a dependency the verify step
140
+ * needs is down) — webhook providers like Stripe retry on 503.
141
+ * `options.retryAfterSeconds` renders as the `Retry-After` header. */
136
142
  export class ExtraRouteRejection extends Error {
137
143
  readonly status: ExtraRouteRejectionStatus;
138
144
  readonly body: unknown;
139
-
140
- constructor(status: ExtraRouteRejectionStatus, body: unknown, message?: string) {
145
+ readonly retryAfterSeconds: number | undefined;
146
+
147
+ constructor(
148
+ status: ExtraRouteRejectionStatus,
149
+ body: unknown,
150
+ message?: string,
151
+ options?: ExtraRouteRejectionOptions,
152
+ ) {
141
153
  super(message ?? `extra route rejected with status ${status}`);
142
154
  this.name = "ExtraRouteRejection";
143
155
  this.status = status;
144
156
  this.body = body;
157
+ const retryAfterSeconds = options?.retryAfterSeconds;
158
+ if (retryAfterSeconds !== undefined) {
159
+ // Retry-After is only meaningful for 503 in this union (RFC 9110) and
160
+ // is rendered as a delta-seconds header — reject anything that could
161
+ // not survive that round-trip.
162
+ if (status !== 503) {
163
+ throw new RangeError(
164
+ "ExtraRouteRejection: retryAfterSeconds is only valid with status 503",
165
+ );
166
+ }
167
+ if (!Number.isInteger(retryAfterSeconds) || retryAfterSeconds < 0) {
168
+ throw new RangeError(
169
+ "ExtraRouteRejection: retryAfterSeconds must be a non-negative integer",
170
+ );
171
+ }
172
+ }
173
+ this.retryAfterSeconds = retryAfterSeconds;
145
174
  }
146
175
  }
package/src/api/index.ts CHANGED
@@ -33,6 +33,7 @@ export type {
33
33
  AnonymousExtraRouteDeps,
34
34
  ExtraRouteDefinition,
35
35
  ExtraRouteEntry,
36
+ ExtraRouteRejectionOptions,
36
37
  ExtraRouteRejectionStatus,
37
38
  SignatureExtraRoute,
38
39
  SignatureExtraRouteDeps,