@cosmicdrift/kumiko-framework 0.383.1 → 0.385.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 (82) hide show
  1. package/agents/examples/custom-fields-basic.md +13 -3
  2. package/agents/examples/designer-demo.md +6 -2
  3. package/agents/examples/i18n.md +36 -1
  4. package/agents/features.md +4 -0
  5. package/dist/api/auth-rate-limiters.d.ts +8 -0
  6. package/dist/api/auth-rate-limiters.js +105 -0
  7. package/dist/api/auth-routes-context.d.ts +34 -0
  8. package/dist/api/auth-routes-context.js +192 -0
  9. package/dist/api/auth-routes-invite.d.ts +2 -0
  10. package/dist/api/auth-routes-invite.js +171 -0
  11. package/dist/api/auth-routes-login.d.ts +2 -0
  12. package/dist/api/auth-routes-login.js +103 -0
  13. package/dist/api/auth-routes-mfa.d.ts +4 -0
  14. package/dist/api/auth-routes-mfa.js +211 -0
  15. package/dist/api/auth-routes-session.d.ts +2 -0
  16. package/dist/api/auth-routes-session.js +144 -0
  17. package/dist/api/auth-routes-shared.d.ts +33 -0
  18. package/dist/api/auth-routes-shared.js +81 -0
  19. package/dist/api/auth-routes-signup.d.ts +2 -0
  20. package/dist/api/auth-routes-signup.js +75 -0
  21. package/dist/api/auth-routes-token-flows.d.ts +4 -0
  22. package/dist/api/auth-routes-token-flows.js +91 -0
  23. package/dist/api/auth-routes-types.d.ts +76 -0
  24. package/dist/api/auth-routes-types.js +1 -0
  25. package/dist/api/auth-routes.d.ts +5 -85
  26. package/dist/api/auth-routes.js +20 -1095
  27. package/dist/config/index.d.ts +1 -1
  28. package/dist/crypto/pii-ciphertext-format.js +1 -1
  29. package/dist/db/blind-index-cleanup.js +1 -1
  30. package/dist/db/dialect.js +1 -1
  31. package/dist/db/distinct-on.d.ts +9 -0
  32. package/dist/db/distinct-on.js +28 -0
  33. package/dist/db/event-store-executor-write.js +2 -2
  34. package/dist/db/migrate-runner.js +4 -4
  35. package/dist/db/pg-error.js +0 -12
  36. package/dist/db/queries/event-consumer.js +3 -3
  37. package/dist/db/queries/postgres-array-literal.js +2 -2
  38. package/dist/db/query.js +48 -20
  39. package/dist/db/tenant-db.js +1 -1
  40. package/dist/engine/boot-validator/i18n-keys.d.ts +2 -0
  41. package/dist/engine/boot-validator/i18n-keys.js +15 -1
  42. package/dist/engine/boot-validator/index.d.ts +4 -0
  43. package/dist/engine/boot-validator/index.js +3 -1
  44. package/dist/engine/boot-validator/pii-retention.js +6 -7
  45. package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
  46. package/dist/engine/boot-validator/screens-actions.js +488 -0
  47. package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
  48. package/dist/engine/boot-validator/screens-dashboard.js +467 -0
  49. package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
  50. package/dist/engine/boot-validator/screens-forms.js +516 -0
  51. package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
  52. package/dist/engine/boot-validator/screens-lists.js +319 -0
  53. package/dist/engine/boot-validator/screens.d.ts +4 -21
  54. package/dist/engine/boot-validator/screens.js +26 -1799
  55. package/dist/engine/build-app-schema.js +1 -1
  56. package/dist/engine/factories.d.ts +3 -3
  57. package/dist/engine/steps/read-find-one.js +1 -1
  58. package/dist/engine/types/index.d.ts +1 -1
  59. package/dist/event-store/provenance-append.js +1 -1
  60. package/dist/files/file-ref-entity.d.ts +6 -126
  61. package/dist/files/file-ref-table.d.ts +6 -126
  62. package/dist/files/file-routes.js +39 -16
  63. package/dist/i18n/required-surface-keys.d.ts +22 -2
  64. package/dist/i18n/required-surface-keys.js +91 -3
  65. package/dist/jobs/job-runner-helpers.d.ts +45 -0
  66. package/dist/jobs/job-runner-helpers.js +281 -0
  67. package/dist/jobs/job-runner-schedulers.d.ts +23 -0
  68. package/dist/jobs/job-runner-schedulers.js +106 -0
  69. package/dist/jobs/job-runner-types.d.ts +65 -0
  70. package/dist/jobs/job-runner-types.js +2 -0
  71. package/dist/jobs/job-runner.d.ts +4 -75
  72. package/dist/jobs/job-runner.js +7 -384
  73. package/dist/pipeline/dispatch-shared.js +2 -2
  74. package/dist/pipeline/event-dispatcher-admin.d.ts +15 -8
  75. package/dist/pipeline/event-dispatcher-admin.js +28 -7
  76. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  77. package/dist/pipeline/event-dispatcher.js +3 -4
  78. package/dist/pipeline/index.d.ts +2 -2
  79. package/dist/pipeline/index.js +1 -1
  80. package/dist/schema-cli.js +5 -0
  81. package/package.json +3 -3
  82. package/src/changes.json +39 -0
@@ -1,179 +1,11 @@
1
- import { createHash } from "node:crypto";
2
- import { Hono } from "hono";
3
- import { deleteCookie, setCookie } from "hono/cookie";
4
- import * as z from "zod";
5
- import { buildSessionRoles } from "../engine/membership-roles.js";
6
- import { createAnonymousUser, createSystemUser } from "../engine/system-user.js";
7
- import { SYSTEM_TENANT_ID, } from "../engine/types/index.js";
8
- import { NotFoundError, toKumikoError, toWriteErrorInfo } from "../errors/index.js";
9
- import { createFallbackLogger } from "../logging/index.js";
10
- import { assertUnreachable } from "../utils/index.js";
11
- import { parseRoles } from "../utils/serialization.js";
12
- import { Routes } from "./api-constants.js";
13
- import { getUser, } from "./auth-middleware.js";
14
- import { clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
15
- import { resolvePostAuthLandingPath } from "./post-auth-landing.js";
16
- import { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, clearSessionCookies, expireLegacySessionCookieVariants, getSessionCookies, resolveSessionCookies, } from "./session-cookies.js";
17
- import { generateToken } from "./tokens.js";
18
- // Double-entry cookie write used at login and switch-tenant. The auth cookie is
19
- // the HttpOnly carrier of the JWT; the csrf cookie is the JS-readable token the
20
- // web client echoes in X-CSRF-Token on every state-changing request. Both
21
- // cookies share lifetime and SameSite so a stale auth-cookie can't outlive
22
- // its csrf partner (or vice versa) and leave the client in a half-logged-in
23
- // state that would trip the csrf-middleware on every retry.
24
- function setAuthCookies(c, opts) {
25
- const { secure, names } = opts.cookies;
26
- const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
27
- const common = {
28
- secure,
29
- sameSite,
30
- path: "/",
31
- maxAge: opts.ttlSeconds,
32
- ...(opts.domain !== undefined && { domain: opts.domain }),
33
- };
34
- // With a Domain, expire the host-only variant first (as clearSessionCookies
35
- // does): after a deploy that sets cookieDomain, the old host-only cookie
36
- // would coexist with the new Domain cookie (RFC 6265 treats name+domain as
37
- // distinct) and the server could bind to the stale host-only token. With
38
- // __Host- names the unprefixed host-only cookie is a leftover from before
39
- // the prefix and is expired too (and vice versa, see
40
- // expireLegacySessionCookieVariants).
41
- if (opts.domain !== undefined) {
42
- deleteCookie(c, AUTH_COOKIE_BASE, { path: "/" });
43
- deleteCookie(c, CSRF_COOKIE_BASE, { path: "/" });
44
- }
45
- expireLegacySessionCookieVariants(c, opts.cookies);
46
- // Must precede the setCookie calls: browsers apply Set-Cookie in order and
47
- // a retired domain equal to the new one is rejected at config time.
48
- // Retired domains only ever carried unprefixed names (a __Host- cookie has no
49
- // Domain), and a __Host- delete with a Domain would be ignored.
50
- for (const retiredDomain of opts.retiredDomains) {
51
- deleteCookie(c, AUTH_COOKIE_BASE, { path: "/", domain: retiredDomain });
52
- deleteCookie(c, CSRF_COOKIE_BASE, { path: "/", domain: retiredDomain });
53
- }
54
- setCookie(c, names.auth, opts.token, { ...common, httpOnly: true });
55
- // Intentionally NOT HttpOnly — the web client has to read this from
56
- // document.cookie to include it in the X-CSRF-Token request header.
57
- setCookie(c, names.csrf, opts.csrfToken, { ...common, httpOnly: false });
58
- }
59
- const INVALID_COOKIE_DOMAIN_CHARS = /[;,\s]/;
60
- function normalizeCookieDomain(domain) {
61
- return domain.replace(/^\./, "").toLowerCase();
62
- }
63
- // A malformed entry would corrupt the Set-Cookie header; an entry equal to the
64
- // current cookieDomain would make login delete the cookie it just sets.
65
- function assertRetiredCookieDomains(retiredCookieDomains, cookieDomain) {
66
- const current = cookieDomain === undefined ? undefined : normalizeCookieDomain(cookieDomain);
67
- for (const entry of retiredCookieDomains ?? []) {
68
- if (entry === "" || normalizeCookieDomain(entry) === "") {
69
- throw new Error("createAuthRoutes: retiredCookieDomains must not contain an empty entry");
70
- }
71
- if (INVALID_COOKIE_DOMAIN_CHARS.test(entry)) {
72
- throw new Error(`createAuthRoutes: retiredCookieDomains entry "${entry}" contains an invalid character (;, comma or whitespace)`);
73
- }
74
- if (normalizeCookieDomain(entry) === current) {
75
- throw new Error(`createAuthRoutes: retiredCookieDomains entry "${entry}" equals cookieDomain; login would delete its own cookie`);
76
- }
77
- }
78
- }
79
- // Body schema for POST /auth/login. Enforced BEFORE rate-limit so that a
80
- // malformed body (`email: 42`, missing password, …) returns 400 instead of
81
- // crashing on `.toLowerCase()` and leaking a 500 that never increments the
82
- // login counter — previous wiring let attackers spam the endpoint without
83
- // tripping the bucket.
84
- const LoginBody = z.object({
85
- email: z.string().min(1),
86
- password: z.string(),
87
- });
88
- // Body schema for POST /auth/mfa/verify. challengeToken is opaque to the
89
- // framework — it's minted and verified entirely by the mfaVerifyHandler
90
- // (auth-mfa owns the token format, TOTP/recovery-code check, and the
91
- // brute-force cap). code covers both 6-digit TOTP and XXXX-XXXX recovery
92
- // codes (9 chars incl. the dash).
93
- const MfaVerifyBody = z.object({
94
- challengeToken: z.string().min(1),
95
- code: z.string().min(6).max(9),
96
- });
97
- // Body schema for POST /auth/mfa/preauth-enable-start. preauthSetupToken is
98
- // opaque to the framework — minted by login.write.ts's mfaStatusChecker
99
- // (auth-mfa) when enforcement policy blocks an unenrolled user, verified
100
- // entirely by the mfaPreauthEnableStartHandler. accountLabel is
101
- // client-supplied (the email the user just typed at login) for the
102
- // otpauth:// URI, mirroring auth-mfa's own enable-start.write.ts.
103
- const MfaPreauthEnableStartBody = z.object({
104
- preauthSetupToken: z.string().min(1),
105
- accountLabel: z.string().min(1).max(200),
106
- });
107
- // Body schema for POST /auth/mfa/preauth-confirm. setupToken is opaque to
108
- // the framework — minted by the mfaPreauthEnableStartHandler, verified
109
- // entirely by the mfaPreauthConfirmHandler. code is TOTP-only (no recovery
110
- // codes at enrollment time), mirroring auth-mfa's own enable-confirm.write.ts.
111
- const MfaPreauthConfirmBody = z.object({
112
- setupToken: z.string().min(1),
113
- code: z.string().length(6),
114
- });
115
- const ResetPasswordBody = z.object({
116
- token: z.string().min(1),
117
- newPassword: z.string().min(8).max(200),
118
- });
119
- const VerifyEmailBody = z.object({
120
- token: z.string().min(1),
121
- });
122
- const SignupConfirmBody = z.object({
123
- token: z.string().min(1),
124
- password: z.string().min(8).max(200),
125
- });
126
- // Cross-device try-first handover (kumiko-framework#3035 follow-up,
127
- // offlot-app#454): an optional tenant-handover grant the signup-request
128
- // route forwards to the handler for server-side verification and binding —
129
- // the grant itself never appears anywhere else (URL, mail, log, response).
130
- const SignupRequestBody = z.object({
131
- email: z.email(),
132
- handover: z
133
- .object({
134
- entityType: z.string().min(1).max(200),
135
- token: z.string().min(1).max(4096),
136
- })
137
- .optional(),
138
- });
139
- const InviteAcceptBody = z.object({
140
- token: z.string().min(1),
141
- });
142
- const InviteAcceptWithLoginBody = z.object({
143
- token: z.string().min(1),
144
- email: z.string().email(),
145
- password: z.string().min(8).max(200),
146
- });
147
- const InviteSignupCompleteBody = z.object({
148
- token: z.string().min(1),
149
- password: z.string().min(8).max(200),
150
- });
151
- const InviteInfoBody = z.object({
152
- token: z.string().min(1),
153
- });
154
- // Shape guard for "handler not registered" — the only legitimate reason to
155
- // fall back to a single-tenant reply on /auth/tenants or /auth/switch-tenant.
156
- // Every other error (DB down, revoker throws, access denied, …) has to
157
- // propagate — otherwise we'd silently paper over outages.
158
- function isUnknownHandlerError(e) {
159
- if (!(e instanceof NotFoundError))
160
- return false;
161
- // @cast-boundary error-details — KumikoError.details shape is per-error
162
- const details = e.details;
163
- return details?.entity === "handler";
164
- }
165
- function switchTenantRejectionResponse(c, reason) {
166
- switch (reason) {
167
- case "not_a_member":
168
- return c.json({ error: "not_a_member" }, 403);
169
- case "principal_blocked":
170
- return c.json({ error: "principal_blocked" }, 403);
171
- case "tenant_teardown":
172
- return c.json({ error: "tenant_unavailable" }, 410);
173
- default:
174
- return assertUnreachable(reason, "ActiveMembershipRejection");
175
- }
176
- }
1
+ import { createAuthRouteContext } from "./auth-routes-context.js";
2
+ import { registerInviteRoutes } from "./auth-routes-invite.js";
3
+ import { registerLoginRoute } from "./auth-routes-login.js";
4
+ import { registerMfaPreauthConfirmRoute, registerMfaPreauthEnableStartRoute, registerMfaVerifyRoute, } from "./auth-routes-mfa.js";
5
+ import { registerSessionRoutes } from "./auth-routes-session.js";
6
+ import { registerSignupRoutes } from "./auth-routes-signup.js";
7
+ import { registerAccountUnlockRoutes, registerEmailVerificationRoutes, registerPasswordResetRoutes, } from "./auth-routes-token-flows.js";
8
+ export { createInMemoryLoginRateLimiter, createRedisLoginRateLimiter, inMemoryLoginRateLimiterFallback, } from "./auth-rate-limiters.js";
177
9
  // A sid minted by sessionCreator is only revocable when logout can revoke it
178
10
  // (sessionRevoker) AND every request re-checks it (sessionChecker). Wiring the
179
11
  // first without the other two would make logout look effective while the JWT
@@ -194,924 +26,17 @@ export function assertSessionCallbacksPaired(auth) {
194
26
  "feature's sessionStore provides them) or none for stateless JWTs.");
195
27
  }
196
28
  }
197
- // Second rate-limit axis for preauth-enable-start: unlike the IP, a
198
- // preauthSetupToken isn't freely choosable by the attacker (it's minted by
199
- // login.write.ts), so hashing it into a bucket key still caps the replay
200
- // even when the IP-based bucket is bypassed via header rotation.
201
- function preauthTokenKeyOf(token) {
202
- return `preauth-token:${createHash("sha256").update(token).digest("hex")}`;
203
- }
204
- // Extract `ip` and `user-agent` for the sessionCreator. Client-IP resolution
205
- // (trustedProxyHops-aware) is the shared resolveClientIp (see client-ip.ts)
206
- // — createAuthRoutes builds one resolver instance below and
207
- // closes over it here and in getClientIp.
208
- function requestMeta(c, resolver) {
209
- const ip = resolver.resolve(clientIpSourceFromHonoContext(c));
210
- const userAgent = c.req.header("user-agent") ?? "unknown";
211
- return { ip, userAgent };
212
- }
213
- let warnedInMemoryLimiterInProduction = false;
214
- // Without Redis-backed limiters each replica counts on its own, so the effective limit scales with replica count.
215
- export function inMemoryLoginRateLimiterFallback() {
216
- if (process.env["NODE_ENV"] === "production" && !warnedInMemoryLimiterInProduction) {
217
- warnedInMemoryLimiterInProduction = true;
218
- createFallbackLogger("auth-routes").warn("login/MFA rate limiter falls back to in-memory; limits are per replica");
219
- }
220
- return createInMemoryLoginRateLimiter();
221
- }
222
- // Default: in-memory fixed window. Fine for a single Node process; for
223
- // multi-process deployments, inject a Redis-backed LoginRateLimiter instead
224
- // so attempts can't be spread across replicas.
225
- //
226
- // Memory management: entries expire after `windowMs` but the map entries
227
- // themselves linger until something touches them. To stop the map from
228
- // growing unbounded (a single attacker can create entries with different
229
- // `ip|email` buckets at ~req rate), we opportunistically sweep expired
230
- // entries when the map crosses `sweepThreshold` keys and hard-cap total
231
- // entries at `maxEntries` — oldest ones get dropped first.
232
- export function createInMemoryLoginRateLimiter(maxAttempts = 10, windowMs = 5 * 60_000, { maxEntries = 10_000, sweepThreshold = 1_000, } = {}) {
233
- const hits = new Map();
234
- function sweepExpired(now) {
235
- for (const [k, entry] of hits) {
236
- if (entry.resetAt <= now)
237
- hits.delete(k);
238
- }
239
- }
240
- function enforceCap() {
241
- // Map iteration is insertion-order, so the oldest entries are first.
242
- // Drop from the front until we're back under the cap.
243
- // skip: under the cap, nothing to do
244
- if (hits.size <= maxEntries)
245
- return;
246
- const toDrop = hits.size - maxEntries;
247
- let dropped = 0;
248
- for (const k of hits.keys()) {
249
- if (dropped >= toDrop)
250
- break;
251
- hits.delete(k);
252
- dropped++;
253
- }
254
- }
255
- return {
256
- async check(key) {
257
- const now = Date.now();
258
- if (hits.size >= sweepThreshold)
259
- sweepExpired(now);
260
- const entry = hits.get(key);
261
- if (!entry || entry.resetAt <= now) {
262
- hits.set(key, { count: 1, resetAt: now + windowMs });
263
- enforceCap();
264
- return true;
265
- }
266
- if (entry.count >= maxAttempts)
267
- return false;
268
- entry.count++;
269
- return true;
270
- },
271
- async reset(key) {
272
- hits.delete(key);
273
- },
274
- };
275
- }
276
- // Redis-backed sibling of createInMemoryLoginRateLimiter — same fixed-window
277
- // semantics (count <= maxAttempts allows, window anchored on first hit), but
278
- // shared across replicas via INCR/PEXPIRE instead of an in-process Map.
279
- // runProdApp defaults to this: an in-memory limiter only rate-limits within
280
- // a single instance, so a multi-replica prod deployment would silently give
281
- // each replica its own bucket. namespace separates the login-key keyspace
282
- // from the mfa-verify one (they share the same LoginRateLimiter shape but
283
- // key on different values).
284
- // Fail-closed on Redis outage, unlike the in-memory limiter (which never
285
- // throws): `check`/`reset` propagate any Redis error, so callers 500
286
- // instead of falling back to unlimited attempts. Deliberate — Redis is
287
- // already required infra for a multi-replica deployment, and for a
288
- // security-relevant limiter "briefly unavailable" should read as "briefly
289
- // down", not "briefly unlimited".
290
- export function createRedisLoginRateLimiter(redis, maxAttempts = 10, windowMs = 5 * 60_000, namespace = "login") {
291
- const prefix = `kumiko:auth:ratelimit:${namespace}:`;
292
- return {
293
- async check(key) {
294
- const redisKey = `${prefix}${key}`;
295
- // INCR then PEXPIRE was two round-trips: a crash/network blip between
296
- // them (only possible right after count===1, when the key is fresh)
297
- // left the key permanently without a TTL — the window never resets,
298
- // the counter only grows, and the bucket is locked out until an
299
- // operator manually resets/deletes the key. One atomic eval closes
300
- // the gap: the TTL is set in the same script invocation that creates
301
- // the key, so no observer (including a crash) can see count===1
302
- // without the expiry already applied. The `PTTL < 0` branch also
303
- // heals keys that already exist without a TTL from before this fix
304
- // shipped (PTTL returns -1 for "no expiry set, key exists") — without
305
- // it, those pre-existing keys would grow forever with no way back to
306
- // a normal window.
307
- const count = (await redis.eval(`local c = redis.call('INCR', KEYS[1])
308
- if c == 1 or redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
309
- return c`, 1, redisKey, windowMs));
310
- return count <= maxAttempts;
311
- },
312
- async reset(key) {
313
- await redis.del(`${prefix}${key}`);
314
- },
315
- };
316
- }
317
- // A missing, malformed or non-object body reads as {} so callers answer with their own 400.
318
- async function readJsonObjectOrEmpty(req) {
319
- const raw = await req.json().catch(() => null);
320
- return raw !== null && typeof raw === "object" ? raw : {};
321
- }
322
29
  export function createAuthRoutes(dispatcher, jwt, config) {
323
- const api = new Hono();
324
- // Default to "lax": CSRF control comes from the double-submit token, and
325
- // "lax" keeps email deep-links (invite, magic-link, notification click)
326
- // working. High-security apps can opt into "strict" — see AuthRoutesConfig.
327
- const cookieSameSite = config.cookieSameSite ?? "lax";
328
- const cookieDomain = config.cookieDomain;
329
- const sessionCookies = resolveSessionCookies({
330
- secureCookies: config.secureCookies,
331
- cookieDomain,
332
- retiredCookieDomains: config.retiredCookieDomains,
333
- });
334
- assertRetiredCookieDomains(config.retiredCookieDomains, cookieDomain);
335
- if (config.mfaVerifyHandler && !config.switchTenantMfaGateHandler) {
336
- throw new Error("createAuthRoutes: mfaVerifyHandler is set but switchTenantMfaGateHandler is not; " +
337
- "POST /auth/switch-tenant would mint admin sessions without the second factor");
338
- }
339
- const retiredCookieDomains = config.retiredCookieDomains ?? [];
340
- // Single hop-count-aware IP getter for every rate-limit call site below —
341
- // see AuthRoutesConfig.trustedProxyHops and client-ip.ts's resolveClientIp
342
- // doc comment. createClientIpResolver fails loud on a non-finite/negative
343
- // value rather than letting it silently collapse every request into the
344
- // shared "unknown" bucket.
345
- const clientIpResolver = config.resolver ?? createClientIpResolver(config.trustedProxyHops ?? 0, "createAuthRoutes");
346
- const getClientIp = (c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
347
- // undefined = no gate wired (setup without MFA). "clearedRoles" are the roles the gate decided on,
348
- // so the minted session cannot differ from what the gate checked.
349
- async function switchTenantMfaGate(c, user, targetTenantId) {
350
- if (!config.switchTenantMfaGateHandler)
351
- return undefined;
352
- const gate = await dispatcher.write(config.switchTenantMfaGateHandler, { userId: user.id, tenantId: targetTenantId }, createSystemUser(user.tenantId));
353
- if (!gate.isSuccess) {
354
- const status = gate.error.httpStatus; // @cast-boundary engine-payload
355
- return { response: c.json({ isSuccess: false, error: gate.error }, status) };
356
- }
357
- // @cast-boundary engine-payload — the gate handler's three-shape result
358
- const gateData = gate.data;
359
- if (gateData.kind === "mfa-gate-clear")
360
- return { clearedRoles: gateData.roles };
361
- return { response: pendingMfaStepResponse(c, gateData) };
362
- }
363
- // Global roles (SystemAdmin etc.) come from the user feature when userQuery is wired, so they survive
364
- // the tenant switch. buildSessionRoles calls stripForbiddenMembershipRoles on the membership side only — a backstop
365
- // for a role a projection rebuild resurrected past command-time validation (engine/membership-roles).
366
- async function resolveSwitchTenantRoles(user, membership) {
367
- let globalRoles = [];
368
- if (config.userQuery) {
369
- try {
370
- // @cast-boundary engine-payload — dispatcher.query returnt unknown,
371
- // Per the AuthUserRow contract, userQuery returns a row with a roles column.
372
- const userRow = (await dispatcher.query(config.userQuery, { id: user.id }, createSystemUser(user.tenantId)));
373
- // roles is jsonb string[] (multiSelect); parseRoles also accepts legacy
374
- // JSON-encoded text from pre-0.217.0 rows.
375
- globalRoles = parseRoles(userRow?.roles ?? null);
376
- }
377
- catch (e) {
378
- // Non-fatal: when global roles cannot be resolved the switch continues with tenant roles
379
- // only. A server error carrying just the cause would hurt the UX more than a switch without
380
- // SystemAdmin, which the user notices and reports.
381
- if (!isUnknownHandlerError(e))
382
- throw e;
383
- }
384
- }
385
- return buildSessionRoles(globalRoles, membership.roles);
386
- }
387
- // Shared tail of every route that ends a request logged-in: create the
388
- // session record (if wired), sign the JWT, set the auth+csrf cookies. Was
389
- // duplicated 5x (login/signup-confirm/invite-accept-with-login/invite-
390
- // signup-complete/switch-tenant) — extracted so a 6th caller (mfa-verify)
391
- // doesn't grow it to 6.
392
- async function mintSessionAndRespond(c, session) {
393
- let sessionForJwt = session;
394
- if (config.sessionCreator) {
395
- const sid = await config.sessionCreator(session, requestMeta(c, clientIpResolver));
396
- sessionForJwt = { ...session, sid };
397
- }
398
- const token = await jwt.sign(sessionForJwt);
399
- const csrfToken = generateToken();
400
- setAuthCookies(c, {
401
- cookies: getSessionCookies(c, sessionCookies),
402
- token,
403
- csrfToken,
404
- sameSite: cookieSameSite,
405
- domain: cookieDomain,
406
- retiredDomains: retiredCookieDomains,
407
- ttlSeconds: jwt.ttlSeconds,
408
- });
409
- return token;
410
- }
411
- // Spreadable landingPath fragment for the 7 auth-response call sites below
412
- // — absent (not `landingPath: undefined`) when unwired/invalid/throwing.
413
- function landingPathFragment(args) {
414
- const landingPath = resolvePostAuthLandingPath(config.postAuthLanding, args);
415
- return landingPath === undefined ? {} : { landingPath };
416
- }
417
- // POST /auth/login — public endpoint (bypasses auth middleware via PUBLIC_API_PATHS).
418
- // The configured login handler authenticates and returns a SessionUser;
419
- // the route signs the JWT and hands it back to the client.
420
- if (config.loginHandler) {
421
- const loginQn = config.loginHandler;
422
- const statusMap = config.loginErrorStatusMap ?? {};
423
- // Default to in-memory limiter unless the caller opted out via null.
424
- const rateLimiter = config.loginRateLimit === null
425
- ? null
426
- : (config.loginRateLimit ?? inMemoryLoginRateLimiterFallback());
427
- api.post(Routes.authLogin, async (c) => {
428
- const raw = await c.req.json().catch(() => null);
429
- const parsed = LoginBody.safeParse(raw);
430
- if (!parsed.success) {
431
- return c.json({ isSuccess: false, error: "invalid_body" }, 400);
432
- }
433
- const body = parsed.data;
434
- const clientIp = getClientIp(c);
435
- const rateLimitKey = `${clientIp}|${body.email.toLowerCase()}`;
436
- if (rateLimiter) {
437
- // Bucket by both IP and email so a single guessed password can't
438
- // block a real user from logging in, but also so one abuser can't
439
- // just cycle emails.
440
- const allowed = await rateLimiter.check(rateLimitKey);
441
- if (!allowed) {
442
- return c.json({ isSuccess: false, error: "rate_limited" }, 429);
443
- }
444
- }
445
- const result = await dispatcher.write(loginQn, body, createAnonymousUser(SYSTEM_TENANT_ID));
446
- if (!result.isSuccess) {
447
- // Feature-specific auth reason codes arrive via UnprocessableError.details.reason
448
- // (e.g. "invalid_credentials", "user_locked"). Fall back to the KumikoError code
449
- // so unmapped cases still get a sensible status.
450
- // @cast-boundary error-details — KumikoError.details shape is per-error
451
- const reason = result.error.details?.reason ?? result.error.code;
452
- // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
453
- const status = (statusMap[reason] ?? result.error.httpStatus);
454
- return c.json({ isSuccess: false, error: result.error }, status);
455
- }
456
- // @cast-boundary engine-payload — generic dispatcher.write result for
457
- // login. Three possible shapes: a straight session, an MFA challenge
458
- // when the loginHandler is wired with a second-factor gate, or a hard
459
- // block when enforcement policy demands MFA but the user never
460
- // enrolled (see auth-mfa's config.ts for why this has no in-band
461
- // recovery yet).
462
- const data = result.data;
463
- if (data.kind === "mfa-setup-required") {
464
- // No session, no challenge — the client must show an
465
- // enrollment-required message. No rate-limit reset (same reasoning
466
- // as the mfa-challenge branch below).
467
- return c.json({
468
- isSuccess: true,
469
- mfaSetupRequired: true,
470
- preauthSetupToken: data.preauthSetupToken,
471
- });
472
- }
473
- if (data.kind === "mfa-challenge") {
474
- // No session minted yet — no cookies, no token. The client must
475
- // complete /auth/mfa/verify with this token before it gets either.
476
- // Rate-limit counter is NOT reset here: a "correct password, wrong/
477
- // no TOTP yet" outcome hasn't proven the caller owns the account any
478
- // more than a wrong password did.
479
- return c.json({ isSuccess: true, mfaRequired: true, challengeToken: data.challengeToken });
480
- }
481
- // Session creation (optional) + JWT sign + cookies — see
482
- // mintSessionAndRespond. Creating the session BEFORE signing the JWT
483
- // is load-bearing: the sid must exist on the server before the token
484
- // that references it can be handed out, otherwise a fast client could
485
- // arrive at an auth-middleware check before the insert commits.
486
- const token = await mintSessionAndRespond(c, data.session);
487
- const landingPath = landingPathFragment({
488
- flow: "login",
489
- roles: data.session.roles,
490
- tenantId: data.session.tenantId,
491
- });
492
- if (rateLimiter) {
493
- await rateLimiter.reset(rateLimitKey);
494
- }
495
- return c.json({
496
- isSuccess: true,
497
- token,
498
- user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
499
- ...landingPath,
500
- });
501
- });
502
- }
503
- // POST /auth/mfa/verify — completes a two-step login. Mirrors /auth/login
504
- // structurally (public, anonymous identity dispatch, mintSessionAndRespond on
505
- // success) but with its OWN rate limiter (mfaVerifyRateLimit) — this route
506
- // never goes through a dispatcher write-handler's own rateLimit config,
507
- // so without this it would have NO rate limiting at all. Per-account
508
- // brute-force protection (capping wrong-code guesses against one
509
- // still-valid challenge token) is a separate mechanism the handler itself
510
- // owns — see AuthRoutesConfig.mfaVerifyHandler's doc comment.
511
- if (config.mfaVerifyHandler) {
512
- const mfaVerifyQn = config.mfaVerifyHandler;
513
- const statusMap = config.mfaVerifyErrorStatusMap ?? {};
514
- const rateLimiter = config.mfaVerifyRateLimit === null
515
- ? null
516
- : (config.mfaVerifyRateLimit ?? inMemoryLoginRateLimiterFallback());
517
- api.post(Routes.authMfaVerify, async (c) => {
518
- const raw = await c.req.json().catch(() => null);
519
- const parsed = MfaVerifyBody.safeParse(raw);
520
- if (!parsed.success) {
521
- return c.json({ isSuccess: false, error: "invalid_body" }, 400);
522
- }
523
- const body = parsed.data;
524
- const clientIp = getClientIp(c);
525
- if (rateLimiter) {
526
- const allowed = await rateLimiter.check(clientIp);
527
- if (!allowed) {
528
- return c.json({ isSuccess: false, error: "rate_limited" }, 429);
529
- }
530
- }
531
- const result = await dispatcher.write(mfaVerifyQn, body, createAnonymousUser(SYSTEM_TENANT_ID));
532
- if (!result.isSuccess) {
533
- // @cast-boundary error-details — KumikoError.details shape is per-error
534
- const reason = result.error.details?.reason ?? result.error.code;
535
- // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
536
- const status = (statusMap[reason] ?? result.error.httpStatus);
537
- return c.json({ isSuccess: false, error: result.error }, status);
538
- }
539
- // @cast-boundary engine-payload — generic dispatcher.write result for mfa-verify handler
540
- const data = result.data;
541
- const token = await mintSessionAndRespond(c, data.session);
542
- const landingPath = landingPathFragment({
543
- flow: "login",
544
- roles: data.session.roles,
545
- tenantId: data.session.tenantId,
546
- });
547
- if (rateLimiter) {
548
- await rateLimiter.reset(clientIp);
549
- }
550
- return c.json({
551
- isSuccess: true,
552
- token,
553
- user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
554
- ...landingPath,
555
- });
556
- });
557
- }
558
- // POST /auth/mfa/preauth-enable-start — lets a user blocked at login by
559
- // MFA enforcement (mfa-setup-required) generate a TOTP secret + QR
560
- // without a session, using the preauthSetupToken login.write.ts issued.
561
- // No JWT is minted here (unlike /auth/login and /auth/mfa/verify) — the
562
- // handler's response carries a secret-bearing setupToken that a later
563
- // pre-auth confirm step (the same shape auth-mfa's own enable-confirm
564
- // consumes) verifies. Rate-limited like mfaVerifyRateLimit/
565
- // mfaPreauthConfirmRateLimit — a preauthSetupToken is valid and
566
- // not-single-use for its whole TTL, so without this cap a replay is a
567
- // memory-hard CPU amplifier (see AuthRoutesConfig doc comment).
568
- if (config.mfaPreauthEnableStartHandler) {
569
- const mfaPreauthEnableStartQn = config.mfaPreauthEnableStartHandler;
570
- const statusMap = config.mfaPreauthEnableStartErrorStatusMap ?? {};
571
- const rateLimiter = config.mfaPreauthEnableStartRateLimit === null
572
- ? null
573
- : (config.mfaPreauthEnableStartRateLimit ?? inMemoryLoginRateLimiterFallback());
574
- api.post(Routes.authMfaPreauthEnableStart, async (c) => {
575
- const raw = await c.req.json().catch(() => null);
576
- const parsed = MfaPreauthEnableStartBody.safeParse(raw);
577
- if (!parsed.success) {
578
- return c.json({ isSuccess: false, error: "invalid_body" }, 400);
579
- }
580
- const body = parsed.data;
581
- const clientIp = getClientIp(c);
582
- if (rateLimiter) {
583
- const allowed = await rateLimiter.check(clientIp);
584
- if (!allowed) {
585
- return c.json({ isSuccess: false, error: "rate_limited" }, 429);
586
- }
587
- // Second axis, independent of the IP-derived bucket above: the
588
- // preauthSetupToken is minted by login.write.ts and not freely
589
- // choosable, so this cap survives an attacker rotating
590
- // x-forwarded-for to bypass the IP bucket (kumiko-framework#1522).
591
- const tokenAllowed = await rateLimiter.check(preauthTokenKeyOf(body.preauthSetupToken));
592
- if (!tokenAllowed) {
593
- return c.json({ isSuccess: false, error: "rate_limited" }, 429);
594
- }
595
- }
596
- const result = await dispatcher.write(mfaPreauthEnableStartQn, body, createAnonymousUser(SYSTEM_TENANT_ID));
597
- if (!result.isSuccess) {
598
- // @cast-boundary error-details — KumikoError.details shape is per-error
599
- const reason = result.error.details?.reason ?? result.error.code;
600
- // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
601
- const status = (statusMap[reason] ?? result.error.httpStatus);
602
- return c.json({ isSuccess: false, error: result.error }, status);
603
- }
604
- // @cast-boundary engine-payload — generic dispatcher.write result for
605
- // the preauth-enable-start handler
606
- const data = result.data;
607
- return c.json({ isSuccess: true, ...data });
608
- });
609
- }
610
- // POST /auth/mfa/preauth-confirm — completes both the enrollment started
611
- // by preauth-enable-start AND the login mfa-setup-required blocked.
612
- // Mirrors /auth/mfa/verify structurally (public, anonymous identity dispatch,
613
- // mintSessionAndRespond on success) with its OWN rate limiter
614
- // (mfaPreauthConfirmRateLimit) — same reasoning as mfaVerifyRateLimit.
615
- // Per-account brute-force protection (capping wrong-code guesses against
616
- // one still-valid setupToken) is owned by the handler itself — see
617
- // AuthRoutesConfig.mfaPreauthConfirmHandler's doc comment.
618
- if (config.mfaPreauthConfirmHandler) {
619
- const mfaPreauthConfirmQn = config.mfaPreauthConfirmHandler;
620
- const statusMap = config.mfaPreauthConfirmErrorStatusMap ?? {};
621
- const rateLimiter = config.mfaPreauthConfirmRateLimit === null
622
- ? null
623
- : (config.mfaPreauthConfirmRateLimit ?? inMemoryLoginRateLimiterFallback());
624
- api.post(Routes.authMfaPreauthConfirm, async (c) => {
625
- const raw = await c.req.json().catch(() => null);
626
- const parsed = MfaPreauthConfirmBody.safeParse(raw);
627
- if (!parsed.success) {
628
- return c.json({ isSuccess: false, error: "invalid_body" }, 400);
629
- }
630
- const body = parsed.data;
631
- const clientIp = getClientIp(c);
632
- if (rateLimiter) {
633
- const allowed = await rateLimiter.check(clientIp);
634
- if (!allowed) {
635
- return c.json({ isSuccess: false, error: "rate_limited" }, 429);
636
- }
637
- }
638
- const result = await dispatcher.write(mfaPreauthConfirmQn, body, createAnonymousUser(SYSTEM_TENANT_ID));
639
- if (!result.isSuccess) {
640
- // @cast-boundary error-details — KumikoError.details shape is per-error
641
- const reason = result.error.details?.reason ?? result.error.code;
642
- // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
643
- const status = (statusMap[reason] ?? result.error.httpStatus);
644
- return c.json({ isSuccess: false, error: result.error }, status);
645
- }
646
- // @cast-boundary engine-payload — generic dispatcher.write result for mfa-preauth-confirm handler
647
- const data = result.data;
648
- const token = await mintSessionAndRespond(c, data.session);
649
- const landingPath = landingPathFragment({
650
- flow: "login",
651
- roles: data.session.roles,
652
- tenantId: data.session.tenantId,
653
- });
654
- if (rateLimiter) {
655
- await rateLimiter.reset(clientIp);
656
- }
657
- return c.json({
658
- isSuccess: true,
659
- token,
660
- user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
661
- ...landingPath,
662
- });
663
- });
664
- }
665
- // POST /auth/request-password-reset + /auth/reset-password — public.
666
- // Silent-success on request (no enumeration), typed failure on confirm.
667
- // Rate-limit covered via config.rateLimit.auth (Sprint G.5 L2, /auth/*).
668
- if (config.passwordReset) {
669
- const pr = config.passwordReset;
670
- registerTokenRequestRoute({
671
- api,
672
- dispatcher,
673
- path: Routes.authRequestPasswordReset,
674
- requestHandler: pr.requestHandler,
675
- });
676
- registerTokenConfirmRoute({
677
- api,
678
- dispatcher,
679
- path: Routes.authResetPassword,
680
- confirmHandler: pr.confirmHandler,
681
- schema: ResetPasswordBody,
682
- });
683
- }
684
- // Email-verification mirrors password-reset.
685
- if (config.emailVerification) {
686
- const ev = config.emailVerification;
687
- registerTokenRequestRoute({
688
- api,
689
- dispatcher,
690
- path: Routes.authRequestEmailVerification,
691
- requestHandler: ev.requestHandler,
692
- });
693
- registerTokenConfirmRoute({
694
- api,
695
- dispatcher,
696
- path: Routes.authVerifyEmail,
697
- confirmHandler: ev.confirmHandler,
698
- schema: VerifyEmailBody,
699
- });
700
- }
701
- // Account-unlock mirrors email-verification (token-only confirm body) —
702
- // clears the Redis lockout state instead of an entity field, see
703
- // confirm-account-unlock.write.ts.
704
- if (config.accountUnlock) {
705
- const au = config.accountUnlock;
706
- registerTokenRequestRoute({
707
- api,
708
- dispatcher,
709
- path: Routes.authRequestAccountUnlock,
710
- requestHandler: au.requestHandler,
711
- });
712
- registerTokenConfirmRoute({
713
- api,
714
- dispatcher,
715
- path: Routes.authConfirmAccountUnlock,
716
- confirmHandler: au.confirmHandler,
717
- schema: VerifyEmailBody,
718
- });
719
- }
720
- // Signup confirm mints the session (auto-login) unless the MFA gate applies;
721
- // then it answers with the MFA step only, no session or cookies.
722
- if (config.signup) {
723
- const sg = config.signup;
724
- registerTokenRequestRoute({
725
- api,
726
- dispatcher,
727
- path: Routes.authSignupRequest,
728
- requestHandler: sg.requestHandler,
729
- schema: SignupRequestBody,
730
- });
731
- api.post(Routes.authSignupConfirm, async (c) => {
732
- const raw = await c.req.json().catch(() => null);
733
- const parsed = SignupConfirmBody.safeParse(raw);
734
- if (!parsed.success) {
735
- return c.json({ isSuccess: false, error: "invalid_body" }, 400);
736
- }
737
- const result = await dispatcher.write(sg.confirmHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
738
- if (!result.isSuccess) {
739
- // 422 für invalid_signup_token (handler-level UnprocessableError).
740
- // @cast-boundary engine-payload — KumikoError.httpStatus narrows to the http-status union
741
- const status = result.error.httpStatus;
742
- return c.json({ isSuccess: false, error: result.error }, status);
743
- }
744
- // @cast-boundary engine-payload — generic dispatcher.write result for signup-confirm
745
- const data = result.data;
746
- if (data.kind !== "auth-session") {
747
- // No session yet, but the account and a claimed handover exist: hand
748
- // the signup landing to the client so the login that follows lands
749
- // where a signup without the MFA gate would have.
750
- return pendingMfaStepResponse(c, data, landingPathFragment({ flow: "signup", ...data.signup }));
751
- }
752
- // Session creation + JWT sign + cookies — see mintSessionAndRespond.
753
- const token = await mintSessionAndRespond(c, data.session);
754
- const signupOutcome = {
755
- roles: data.session.roles,
756
- tenantId: data.session.tenantId,
757
- tenantKey: data.tenantKey,
758
- ...(data.handover !== undefined && { handover: data.handover }),
759
- };
760
- const landingPath = landingPathFragment({ flow: "signup", ...signupOutcome });
761
- return c.json({
762
- isSuccess: true,
763
- token,
764
- user: {
765
- id: data.session.id,
766
- tenantId: data.session.tenantId,
767
- roles: data.session.roles,
768
- },
769
- // tenantKey für Post-Signup-Redirect zu /<tenantKey>/.
770
- // Anders als der login-response der nur `tenants[]` braucht
771
- // (User wählt im Switcher), kennt der signup nur EINE
772
- // membership — die Frontend-UI nimmt das direkt als Redirect-
773
- // Target.
774
- tenantKey: data.tenantKey,
775
- // Cross-device try-first handover (kumiko-framework#3035 follow-up):
776
- // present only when signup-confirm actually claimed a bound grant —
777
- // absent, not null, so a client that doesn't know the field sees
778
- // nothing unusual.
779
- ...(data.handover !== undefined && { handover: data.handover }),
780
- ...landingPath,
781
- });
782
- });
783
- }
784
- // Tenant-Invite Magic-Link. 3 separate Routes für 3 Accept-Branches:
785
- if (config.invite) {
786
- const inv = config.invite;
787
- // Branch 1: logged-in User klickt Invite-Link → Membership-Add im
788
- // invited Tenant (NICHT Tenant-Switch — User bleibt in seiner
789
- // aktuellen Session, kann später via Tenant-Switcher wechseln).
790
- // Requires JWT (siehe PUBLIC_API_PATHS — invite-accept ist NICHT
791
- // public, im Gegensatz zu acceptWithLogin/signupComplete).
792
- api.post(Routes.authInviteAccept, async (c) => {
793
- const user = getUser(c);
794
- const raw = await c.req.json().catch(() => null);
795
- const parsed = InviteAcceptBody.safeParse(raw);
796
- if (!parsed.success) {
797
- return c.json({ isSuccess: false, error: "invalid_body" }, 400);
798
- }
799
- const result = await dispatcher.write(inv.acceptHandler, parsed.data, user);
800
- if (!result.isSuccess) {
801
- // @cast-boundary engine-payload — KumikoError.httpStatus
802
- const status = result.error.httpStatus;
803
- return c.json({ isSuccess: false, error: result.error }, status);
804
- }
805
- // @cast-boundary engine-payload — generic dispatcher.write result
806
- const data = result.data;
807
- const landingPath = landingPathFragment({
808
- flow: "invite",
809
- roles: [data.role],
810
- tenantId: data.tenantId,
811
- });
812
- return c.json({
813
- isSuccess: true,
814
- tenantId: data.tenantId,
815
- role: data.role,
816
- alreadyMember: data.alreadyMember,
817
- ...landingPath,
818
- });
819
- });
820
- // Branch 2: anon User mit existing email — Login + Accept in einem
821
- // Roundtrip. JWT-mint analog signup-confirm.
822
- api.post(Routes.authInviteAcceptWithLogin, async (c) => {
823
- const raw = await c.req.json().catch(() => null);
824
- const parsed = InviteAcceptWithLoginBody.safeParse(raw);
825
- if (!parsed.success) {
826
- return c.json({ isSuccess: false, error: "invalid_body" }, 400);
827
- }
828
- const result = await dispatcher.write(inv.acceptWithLoginHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
829
- if (!result.isSuccess) {
830
- const status = result.error.httpStatus; // @cast-boundary engine-payload
831
- return c.json({ isSuccess: false, error: result.error }, status);
832
- }
833
- // @cast-boundary engine-payload — same three-shape union as /auth/login
834
- // (see gateEnforceMfa): a straight session, an MFA challenge, or a
835
- // hard mfa-setup-required block. Only the auth-session branch also
836
- // carries tenantId/role (invite-specific).
837
- const data = result.data;
838
- if (data.kind === "mfa-setup-required") {
839
- return c.json({
840
- isSuccess: true,
841
- mfaSetupRequired: true,
842
- preauthSetupToken: data.preauthSetupToken,
843
- });
844
- }
845
- if (data.kind === "mfa-challenge") {
846
- return c.json({ isSuccess: true, mfaRequired: true, challengeToken: data.challengeToken });
847
- }
848
- const token = await mintSessionAndRespond(c, data.session);
849
- const landingPath = landingPathFragment({
850
- flow: "invite",
851
- roles: data.session.roles,
852
- tenantId: data.tenantId,
853
- });
854
- return c.json({
855
- isSuccess: true,
856
- token,
857
- user: {
858
- id: data.session.id,
859
- tenantId: data.session.tenantId,
860
- roles: data.session.roles,
861
- },
862
- tenantId: data.tenantId,
863
- role: data.role,
864
- ...landingPath,
865
- });
866
- });
867
- // Branch 3: anon User mit neuer email — User+Membership entstehen,
868
- // KEIN neuer Tenant. JWT-mint.
869
- api.post(Routes.authInviteSignupComplete, async (c) => {
870
- const raw = await c.req.json().catch(() => null);
871
- const parsed = InviteSignupCompleteBody.safeParse(raw);
872
- if (!parsed.success) {
873
- return c.json({ isSuccess: false, error: "invalid_body" }, 400);
874
- }
875
- const result = await dispatcher.write(inv.signupCompleteHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
876
- if (!result.isSuccess) {
877
- const status = result.error.httpStatus; // @cast-boundary engine-payload
878
- return c.json({ isSuccess: false, error: result.error }, status);
879
- }
880
- // @cast-boundary engine-payload — same three-shape union as invite-accept-with-login
881
- const data = result.data;
882
- if (data.kind !== "auth-session")
883
- return pendingMfaStepResponse(c, data);
884
- const token = await mintSessionAndRespond(c, data.session);
885
- const landingPath = landingPathFragment({
886
- flow: "invite",
887
- roles: data.session.roles,
888
- tenantId: data.tenantId,
889
- });
890
- return c.json({
891
- isSuccess: true,
892
- token,
893
- user: {
894
- id: data.session.id,
895
- tenantId: data.session.tenantId,
896
- roles: data.session.roles,
897
- },
898
- tenantId: data.tenantId,
899
- role: data.role,
900
- ...landingPath,
901
- });
902
- });
903
- // Anonymous, read-only invite lookup for the acceptance page: pre-fill
904
- // the invited email and pick Branch 2 (existing account) vs Branch 3
905
- // (new account) before the user submits. Token is never consumed here.
906
- if (inv.infoHandler) {
907
- const infoHandler = inv.infoHandler;
908
- api.post(Routes.authInviteInfo, async (c) => {
909
- const raw = await c.req.json().catch(() => null);
910
- const parsed = InviteInfoBody.safeParse(raw);
911
- if (!parsed.success) {
912
- return c.json({ isSuccess: false, error: "invalid_body" }, 400);
913
- }
914
- try {
915
- // @cast-boundary engine-payload — generic dispatcher.query result
916
- const data = (await dispatcher.query(infoHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID)));
917
- return c.json({ isSuccess: true, ...data });
918
- }
919
- catch (e) {
920
- const err = toKumikoError(e);
921
- // Same body shape as the write routes' `{ isSuccess: false, error:
922
- // result.error }` (WriteErrorInfo) — this is a query, not a write,
923
- // so there's no WriteResult to read `.error` off; toWriteErrorInfo
924
- // builds the equivalent shape directly from the caught error.
925
- return c.json({ isSuccess: false, error: toWriteErrorInfo(err) }, err.httpStatus);
926
- }
927
- });
928
- }
929
- }
930
- // POST /auth/logout — revokes the current session. Requires a valid JWT so
931
- // the middleware has already populated `user.sid` from the `jti` claim. If
932
- // the app hasn't wired a sessionRevoker, logout is effectively a no-op on
933
- // the server — the client can just drop the token.
934
- api.post(Routes.authLogout, async (c) => {
935
- const user = getUser(c);
936
- if (config.sessionRevoker && user.sid) {
937
- await config.sessionRevoker(user.sid);
938
- }
939
- // Clear cookies on the cookie-transport path. Idempotent — clearing a
940
- // missing cookie is a no-op, so bearer-only clients aren't affected.
941
- clearSessionCookies(c, getSessionCookies(c, sessionCookies));
942
- return c.json({ isSuccess: true });
943
- });
944
- // GET /auth/tenants — list tenants the current user belongs to
945
- api.get(Routes.authTenants, async (c) => {
946
- const user = getUser(c);
947
- try {
948
- // System-scoped: membershipQuery is access-locked to system-role.
949
- // @cast-boundary engine-payload — generic dispatcher.query result
950
- const memberships = (await dispatcher.query(config.membershipQuery, { userId: user.id }, createSystemUser(user.tenantId)));
951
- return c.json({
952
- tenants: memberships.map((m) => ({
953
- tenantId: m.tenantId,
954
- roles: m.roles,
955
- ...(m.tenantName !== undefined && { name: m.tenantName }),
956
- ...(m.tenantKey !== undefined && { key: m.tenantKey }),
957
- })),
958
- activeTenantId: user.tenantId,
959
- });
960
- }
961
- catch (e) {
962
- // Only legitimate fallback: the app hasn't wired membershipQuery at
963
- // all. A DB fault or a permission failure has to bubble up so ops
964
- // sees it — collapsing them into "just your current tenant" hides
965
- // outages behind a UI that looks fine.
966
- if (!isUnknownHandlerError(e))
967
- throw e;
968
- return c.json({
969
- tenants: [{ tenantId: user.tenantId, roles: [...user.roles] }],
970
- activeTenantId: user.tenantId,
971
- });
972
- }
973
- });
974
- // POST /auth/switch-tenant — switch to a different tenant
975
- api.post(Routes.authSwitchTenant, async (c) => {
976
- const user = getUser(c);
977
- const body = await readJsonObjectOrEmpty(c.req);
978
- // The id seeds a SYSTEM identity in resolveActiveMembership — reject anything
979
- // that is not a non-empty string before it gets that far.
980
- if (!isNonEmptyString(body.tenantId)) {
981
- return c.json({ error: "invalid_tenant" }, 400);
982
- }
983
- const targetTenantId = body.tenantId; // @cast-boundary request-body
984
- if (targetTenantId === user.tenantId) {
985
- return c.json({ error: "already_in_tenant" }, 400);
986
- }
987
- // Membership + principal-blocked + tenant-teardown in one building
988
- // block — trusted server code asking on the user's behalf, same trust boundary as before.
989
- let activeMembership;
990
- try {
991
- activeMembership = await dispatcher.resolveActiveMembership(user.id, targetTenantId);
992
- }
993
- catch (e) {
994
- // No membershipQuery wired → switching tenants is just not offered in
995
- // this deployment. Any other error propagates so a broken query handler
996
- // surfaces as a real 5xx instead of a misleading 400.
997
- if (!isUnknownHandlerError(e))
998
- throw e;
999
- return c.json({ error: "tenant_switch_not_available" }, 400);
1000
- }
1001
- if (activeMembership.kind === "rejected") {
1002
- return switchTenantRejectionResponse(c, activeMembership.reason);
1003
- }
1004
- const membership = activeMembership.membership;
1005
- const gate = await switchTenantMfaGate(c, user, targetTenantId);
1006
- if (gate !== undefined && "response" in gate)
1007
- return gate.response;
1008
- const sessionRoles = gate !== undefined ? gate.clearedRoles : await resolveSwitchTenantRoles(user, membership);
1009
- // Issue new JWT with the target tenant and its roles. Claims MUST be
1010
- // recomputed for the new tenant — stale claims from the previous
1011
- // tenant would leak identity facts across tenancies (e.g. teamId from
1012
- // tenant A accidentally surviving into tenant B's session). The
1013
- // resolver runs each feature's r.authClaims() hook under the new
1014
- // TenantDb scope.
1015
- const targetSession = {
1016
- id: user.id,
1017
- tenantId: targetTenantId,
1018
- roles: sessionRoles,
1019
- // Tenant-independent prefs must survive the switch (fw#2343).
1020
- ...tenantIndependentPrefs(user),
1021
- };
1022
- const claims = await dispatcher.resolveAuthClaims(targetSession);
1023
- const sessionForJwt = Object.keys(claims).length > 0 ? { ...targetSession, claims } : targetSession;
1024
- // Session rotation: revoke old sid BEFORE creating the new one so a
1025
- // creator failure leaves the user logged-out cleanly rather than with
1026
- // two live sessions. Client must log in again on creator-throw. A
1027
- // revoker/creator that actually throws (Redis down, DB deadlock) surfaces
1028
- // as a 5xx — swallowing it into tenant_switch_not_available was hiding
1029
- // real outages.
1030
- if (config.sessionRevoker && user.sid) {
1031
- await config.sessionRevoker(user.sid);
1032
- }
1033
- // Session creation + JWT sign + cookies — see mintSessionAndRespond. A
1034
- // fresh csrfToken is minted (inside the helper) so a compromised csrf-
1035
- // value can't cross a tenant boundary.
1036
- const newToken = await mintSessionAndRespond(c, sessionForJwt);
1037
- return c.json({ token: newToken, tenantId: targetTenantId, roles: sessionRoles });
1038
- });
1039
- return api;
1040
- }
1041
- function pendingMfaStepResponse(c, step, landing = {}) {
1042
- if (step.kind === "mfa-setup-required") {
1043
- return c.json({
1044
- isSuccess: true,
1045
- mfaSetupRequired: true,
1046
- preauthSetupToken: step.preauthSetupToken,
1047
- ...landing,
1048
- });
1049
- }
1050
- return c.json({
1051
- isSuccess: true,
1052
- mfaRequired: true,
1053
- challengeToken: step.challengeToken,
1054
- ...landing,
1055
- });
1056
- }
1057
- // --- shared route builders for token flows ---------------------------------
1058
- // Password-reset, email-verification and signup share the exact same request
1059
- // HTTP-shape: parse body → dispatch the request handler → always-200 (no
1060
- // enumeration). The handler mints the token AND dispatches the magic-link mail
1061
- // via delivery (ctx.notify); the route never sees the token. confirm-route
1062
- // validates the token + does the state change → typed failure or 200. Keeping
1063
- // the silent-success invariant in one place means changing how the framework
1064
- // handles "invalid_body" on a public token endpoint is one edit, not three.
1065
- function tenantIndependentPrefs(user) {
1066
- return {
1067
- ...(user.timezone ? { timezone: user.timezone } : {}),
1068
- ...(user.locale ? { locale: user.locale } : {}),
1069
- };
1070
- }
1071
- function isNonEmptyString(value) {
1072
- return typeof value === "string" && value.length > 0;
1073
- }
1074
- function registerTokenRequestRoute(opts) {
1075
- const body = opts.schema ?? RequestTokenBody;
1076
- opts.api.post(opts.path, async (c) => {
1077
- const raw = await c.req.json().catch(() => null);
1078
- const parsed = body.safeParse(raw);
1079
- // Malformed body → silent success. A probing client mustn't learn
1080
- // anything from the shape of their input.
1081
- if (!parsed.success)
1082
- return c.json({ isSuccess: true });
1083
- // The handler dispatches the magic-link mail via delivery before returning.
1084
- // Handler-level failures (misconfiguration, e.g. no RateLimitResolver) stay
1085
- // out of the response so it is uniform for unknown vs. known emails, but
1086
- // they are logged — otherwise the mail silently never goes out. No email in
1087
- // the log line (PII).
1088
- const result = await opts.dispatcher.write(opts.requestHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
1089
- // rate_limited is an expected, attacker-triggerable outcome; logging it
1090
- // would flood the sink and bury real misconfiguration errors.
1091
- if (!result.isSuccess && result.error.code !== "rate_limited") {
1092
- console.error(`[kumiko] token request handler "${opts.requestHandler}" failed: ${result.error.code}`);
1093
- }
1094
- return c.json({ isSuccess: true });
1095
- });
1096
- }
1097
- function registerTokenConfirmRoute(opts) {
1098
- opts.api.post(opts.path, async (c) => {
1099
- const raw = await c.req.json().catch(() => null);
1100
- const parsed = opts.schema.safeParse(raw);
1101
- if (!parsed.success) {
1102
- return c.json({ isSuccess: false, error: "invalid_body" }, 400);
1103
- }
1104
- const result = await opts.dispatcher.write(opts.confirmHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
1105
- if (!result.isSuccess) {
1106
- const status = result.error.httpStatus; // @cast-boundary engine-payload
1107
- return c.json({ isSuccess: false, error: result.error }, status);
1108
- }
1109
- return c.json({ isSuccess: true });
1110
- });
30
+ const ctx = createAuthRouteContext(dispatcher, jwt, config);
31
+ registerLoginRoute(ctx);
32
+ registerMfaVerifyRoute(ctx);
33
+ registerMfaPreauthEnableStartRoute(ctx);
34
+ registerMfaPreauthConfirmRoute(ctx);
35
+ registerPasswordResetRoutes(ctx);
36
+ registerEmailVerificationRoutes(ctx);
37
+ registerAccountUnlockRoutes(ctx);
38
+ registerSignupRoutes(ctx);
39
+ registerInviteRoutes(ctx);
40
+ registerSessionRoutes(ctx);
41
+ return ctx.api;
1111
42
  }
1112
- // Shared request-body shape for request-password-reset + request-email-
1113
- // verification. Extracted so the two flows stay in sync when the schema
1114
- // gains optional fields (locale, deviceId, …).
1115
- const RequestTokenBody = z.object({
1116
- email: z.email(),
1117
- });