@cosmicdrift/kumiko-framework 0.384.0 → 0.386.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 (78) hide show
  1. package/agents/examples/i18n.md +36 -1
  2. package/agents/features.md +4 -0
  3. package/dist/api/auth-rate-limiters.d.ts +8 -0
  4. package/dist/api/auth-rate-limiters.js +105 -0
  5. package/dist/api/auth-routes-context.d.ts +34 -0
  6. package/dist/api/auth-routes-context.js +192 -0
  7. package/dist/api/auth-routes-invite.d.ts +2 -0
  8. package/dist/api/auth-routes-invite.js +171 -0
  9. package/dist/api/auth-routes-login.d.ts +2 -0
  10. package/dist/api/auth-routes-login.js +103 -0
  11. package/dist/api/auth-routes-mfa.d.ts +4 -0
  12. package/dist/api/auth-routes-mfa.js +211 -0
  13. package/dist/api/auth-routes-session.d.ts +2 -0
  14. package/dist/api/auth-routes-session.js +144 -0
  15. package/dist/api/auth-routes-shared.d.ts +33 -0
  16. package/dist/api/auth-routes-shared.js +81 -0
  17. package/dist/api/auth-routes-signup.d.ts +2 -0
  18. package/dist/api/auth-routes-signup.js +75 -0
  19. package/dist/api/auth-routes-token-flows.d.ts +4 -0
  20. package/dist/api/auth-routes-token-flows.js +91 -0
  21. package/dist/api/auth-routes-types.d.ts +76 -0
  22. package/dist/api/auth-routes-types.js +1 -0
  23. package/dist/api/auth-routes.d.ts +5 -85
  24. package/dist/api/auth-routes.js +20 -1095
  25. package/dist/api/request-context.d.ts +2 -0
  26. package/dist/api/request-context.js +16 -7
  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/cross-tenant-reads.d.ts +8 -0
  31. package/dist/db/cross-tenant-reads.js +23 -0
  32. package/dist/db/dialect.js +1 -1
  33. package/dist/db/distinct-on.d.ts +9 -0
  34. package/dist/db/distinct-on.js +28 -0
  35. package/dist/db/event-store-executor-write.js +2 -2
  36. package/dist/db/migrate-runner.js +4 -4
  37. package/dist/db/pg-error.js +0 -12
  38. package/dist/db/queries/event-consumer.js +3 -3
  39. package/dist/db/queries/postgres-array-literal.js +2 -2
  40. package/dist/db/queries/test-stack.d.ts +4 -1
  41. package/dist/db/queries/test-stack.js +15 -2
  42. package/dist/db/query.js +19 -19
  43. package/dist/db/tenant-db.js +1 -1
  44. package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
  45. package/dist/engine/boot-validator/i18n-keys.js +8 -1
  46. package/dist/engine/boot-validator/index.d.ts +4 -0
  47. package/dist/engine/boot-validator/index.js +2 -1
  48. package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
  49. package/dist/engine/boot-validator/screens-actions.js +488 -0
  50. package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
  51. package/dist/engine/boot-validator/screens-dashboard.js +467 -0
  52. package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
  53. package/dist/engine/boot-validator/screens-forms.js +516 -0
  54. package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
  55. package/dist/engine/boot-validator/screens-lists.js +319 -0
  56. package/dist/engine/boot-validator/screens.d.ts +4 -21
  57. package/dist/engine/boot-validator/screens.js +26 -1799
  58. package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -0
  59. package/dist/engine/steps/read-find-one.js +1 -1
  60. package/dist/event-store/provenance-append.js +1 -1
  61. package/dist/files/file-routes.js +1 -1
  62. package/dist/i18n/required-surface-keys.d.ts +16 -0
  63. package/dist/i18n/required-surface-keys.js +62 -0
  64. package/dist/internal/stack-support.d.ts +1 -1
  65. package/dist/internal/stack-support.js +1 -1
  66. package/dist/jobs/job-runner-helpers.d.ts +44 -0
  67. package/dist/jobs/job-runner-helpers.js +265 -0
  68. package/dist/jobs/job-runner-schedulers.d.ts +23 -0
  69. package/dist/jobs/job-runner-schedulers.js +106 -0
  70. package/dist/jobs/job-runner-types.d.ts +65 -0
  71. package/dist/jobs/job-runner-types.js +2 -0
  72. package/dist/jobs/job-runner.d.ts +4 -75
  73. package/dist/jobs/job-runner.js +10 -386
  74. package/dist/pipeline/dispatch-shared.js +29 -4
  75. package/dist/pipeline/event-dispatcher.js +2 -3
  76. package/dist/schema-cli.js +5 -0
  77. package/package.json +3 -3
  78. package/src/changes.json +19 -0
@@ -0,0 +1,103 @@
1
+ import * as z from "zod";
2
+ import { createAnonymousUser } from "../engine/system-user.js";
3
+ import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
4
+ import { Routes } from "./api-constants.js";
5
+ import { inMemoryLoginRateLimiterFallback } from "./auth-rate-limiters.js";
6
+ // Body schema for POST /auth/login. Enforced BEFORE rate-limit so that a
7
+ // malformed body (`email: 42`, missing password, …) returns 400 instead of
8
+ // crashing on `.toLowerCase()` and leaking a 500 that never increments the
9
+ // login counter — previous wiring let attackers spam the endpoint without
10
+ // tripping the bucket.
11
+ const LoginBody = z.object({
12
+ email: z.string().min(1),
13
+ password: z.string(),
14
+ });
15
+ export function registerLoginRoute(ctx) {
16
+ const { api, dispatcher, config, getClientIp, mintSessionAndRespond, landingPathFragment } = ctx;
17
+ // POST /auth/login — public endpoint (bypasses auth middleware via PUBLIC_API_PATHS).
18
+ // The configured login handler authenticates and returns a SessionUser;
19
+ // the route signs the JWT and hands it back to the client.
20
+ if (config.loginHandler) {
21
+ const loginQn = config.loginHandler;
22
+ const statusMap = config.loginErrorStatusMap ?? {};
23
+ // Default to in-memory limiter unless the caller opted out via null.
24
+ const rateLimiter = config.loginRateLimit === null
25
+ ? null
26
+ : (config.loginRateLimit ?? inMemoryLoginRateLimiterFallback());
27
+ api.post(Routes.authLogin, async (c) => {
28
+ const raw = await c.req.json().catch(() => null);
29
+ const parsed = LoginBody.safeParse(raw);
30
+ if (!parsed.success) {
31
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
32
+ }
33
+ const body = parsed.data;
34
+ const clientIp = getClientIp(c);
35
+ const rateLimitKey = `${clientIp}|${body.email.toLowerCase()}`;
36
+ if (rateLimiter) {
37
+ // Bucket by both IP and email so a single guessed password can't
38
+ // block a real user from logging in, but also so one abuser can't
39
+ // just cycle emails.
40
+ const allowed = await rateLimiter.check(rateLimitKey);
41
+ if (!allowed) {
42
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
43
+ }
44
+ }
45
+ const result = await dispatcher.write(loginQn, body, createAnonymousUser(SYSTEM_TENANT_ID));
46
+ if (!result.isSuccess) {
47
+ // Feature-specific auth reason codes arrive via UnprocessableError.details.reason
48
+ // (e.g. "invalid_credentials", "user_locked"). Fall back to the KumikoError code
49
+ // so unmapped cases still get a sensible status.
50
+ // @cast-boundary error-details — KumikoError.details shape is per-error
51
+ const reason = result.error.details?.reason ?? result.error.code;
52
+ // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
53
+ const status = (statusMap[reason] ?? result.error.httpStatus);
54
+ return c.json({ isSuccess: false, error: result.error }, status);
55
+ }
56
+ // @cast-boundary engine-payload — generic dispatcher.write result for
57
+ // login. Three possible shapes: a straight session, an MFA challenge
58
+ // when the loginHandler is wired with a second-factor gate, or a hard
59
+ // block when enforcement policy demands MFA but the user never
60
+ // enrolled (see auth-mfa's config.ts for why this has no in-band
61
+ // recovery yet).
62
+ const data = result.data;
63
+ if (data.kind === "mfa-setup-required") {
64
+ // No session, no challenge — the client must show an
65
+ // enrollment-required message. No rate-limit reset (same reasoning
66
+ // as the mfa-challenge branch below).
67
+ return c.json({
68
+ isSuccess: true,
69
+ mfaSetupRequired: true,
70
+ preauthSetupToken: data.preauthSetupToken,
71
+ });
72
+ }
73
+ if (data.kind === "mfa-challenge") {
74
+ // No session minted yet — no cookies, no token. The client must
75
+ // complete /auth/mfa/verify with this token before it gets either.
76
+ // Rate-limit counter is NOT reset here: a "correct password, wrong/
77
+ // no TOTP yet" outcome hasn't proven the caller owns the account any
78
+ // more than a wrong password did.
79
+ return c.json({ isSuccess: true, mfaRequired: true, challengeToken: data.challengeToken });
80
+ }
81
+ // Session creation (optional) + JWT sign + cookies — see
82
+ // mintSessionAndRespond. Creating the session BEFORE signing the JWT
83
+ // is load-bearing: the sid must exist on the server before the token
84
+ // that references it can be handed out, otherwise a fast client could
85
+ // arrive at an auth-middleware check before the insert commits.
86
+ const token = await mintSessionAndRespond(c, data.session);
87
+ const landingPath = landingPathFragment({
88
+ flow: "login",
89
+ roles: data.session.roles,
90
+ tenantId: data.session.tenantId,
91
+ });
92
+ if (rateLimiter) {
93
+ await rateLimiter.reset(rateLimitKey);
94
+ }
95
+ return c.json({
96
+ isSuccess: true,
97
+ token,
98
+ user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
99
+ ...landingPath,
100
+ });
101
+ });
102
+ }
103
+ }
@@ -0,0 +1,4 @@
1
+ import type { AuthRouteContext } from "./auth-routes-context.ts";
2
+ export declare function registerMfaVerifyRoute(ctx: AuthRouteContext): void;
3
+ export declare function registerMfaPreauthEnableStartRoute(ctx: AuthRouteContext): void;
4
+ export declare function registerMfaPreauthConfirmRoute(ctx: AuthRouteContext): void;
@@ -0,0 +1,211 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as z from "zod";
3
+ import { createAnonymousUser } from "../engine/system-user.js";
4
+ import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
5
+ import { Routes } from "./api-constants.js";
6
+ import { inMemoryLoginRateLimiterFallback } from "./auth-rate-limiters.js";
7
+ // Body schema for POST /auth/mfa/verify. challengeToken is opaque to the
8
+ // framework — it's minted and verified entirely by the mfaVerifyHandler
9
+ // (auth-mfa owns the token format, TOTP/recovery-code check, and the
10
+ // brute-force cap). code covers both 6-digit TOTP and XXXX-XXXX recovery
11
+ // codes (9 chars incl. the dash).
12
+ const MfaVerifyBody = z.object({
13
+ challengeToken: z.string().min(1),
14
+ code: z.string().min(6).max(9),
15
+ });
16
+ // Body schema for POST /auth/mfa/preauth-enable-start. preauthSetupToken is
17
+ // opaque to the framework — minted by login.write.ts's mfaStatusChecker
18
+ // (auth-mfa) when enforcement policy blocks an unenrolled user, verified
19
+ // entirely by the mfaPreauthEnableStartHandler. accountLabel is
20
+ // client-supplied (the email the user just typed at login) for the
21
+ // otpauth:// URI, mirroring auth-mfa's own enable-start.write.ts.
22
+ const MfaPreauthEnableStartBody = z.object({
23
+ preauthSetupToken: z.string().min(1),
24
+ accountLabel: z.string().min(1).max(200),
25
+ });
26
+ // Body schema for POST /auth/mfa/preauth-confirm. setupToken is opaque to
27
+ // the framework — minted by the mfaPreauthEnableStartHandler, verified
28
+ // entirely by the mfaPreauthConfirmHandler. code is TOTP-only (no recovery
29
+ // codes at enrollment time), mirroring auth-mfa's own enable-confirm.write.ts.
30
+ const MfaPreauthConfirmBody = z.object({
31
+ setupToken: z.string().min(1),
32
+ code: z.string().length(6),
33
+ });
34
+ // Second rate-limit axis for preauth-enable-start: unlike the IP, a
35
+ // preauthSetupToken isn't freely choosable by the attacker (it's minted by
36
+ // login.write.ts), so hashing it into a bucket key still caps the replay
37
+ // even when the IP-based bucket is bypassed via header rotation.
38
+ function preauthTokenKeyOf(token) {
39
+ return `preauth-token:${createHash("sha256").update(token).digest("hex")}`;
40
+ }
41
+ export function registerMfaVerifyRoute(ctx) {
42
+ const { api, dispatcher, config, getClientIp, mintSessionAndRespond, landingPathFragment } = ctx;
43
+ // POST /auth/mfa/verify — completes a two-step login. Mirrors /auth/login
44
+ // structurally (public, anonymous identity dispatch, mintSessionAndRespond on
45
+ // success) but with its OWN rate limiter (mfaVerifyRateLimit) — this route
46
+ // never goes through a dispatcher write-handler's own rateLimit config,
47
+ // so without this it would have NO rate limiting at all. Per-account
48
+ // brute-force protection (capping wrong-code guesses against one
49
+ // still-valid challenge token) is a separate mechanism the handler itself
50
+ // owns — see AuthRoutesConfig.mfaVerifyHandler's doc comment.
51
+ if (config.mfaVerifyHandler) {
52
+ const mfaVerifyQn = config.mfaVerifyHandler;
53
+ const statusMap = config.mfaVerifyErrorStatusMap ?? {};
54
+ const rateLimiter = config.mfaVerifyRateLimit === null
55
+ ? null
56
+ : (config.mfaVerifyRateLimit ?? inMemoryLoginRateLimiterFallback());
57
+ api.post(Routes.authMfaVerify, async (c) => {
58
+ const raw = await c.req.json().catch(() => null);
59
+ const parsed = MfaVerifyBody.safeParse(raw);
60
+ if (!parsed.success) {
61
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
62
+ }
63
+ const body = parsed.data;
64
+ const clientIp = getClientIp(c);
65
+ if (rateLimiter) {
66
+ const allowed = await rateLimiter.check(clientIp);
67
+ if (!allowed) {
68
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
69
+ }
70
+ }
71
+ const result = await dispatcher.write(mfaVerifyQn, body, createAnonymousUser(SYSTEM_TENANT_ID));
72
+ if (!result.isSuccess) {
73
+ // @cast-boundary error-details — KumikoError.details shape is per-error
74
+ const reason = result.error.details?.reason ?? result.error.code;
75
+ // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
76
+ const status = (statusMap[reason] ?? result.error.httpStatus);
77
+ return c.json({ isSuccess: false, error: result.error }, status);
78
+ }
79
+ // @cast-boundary engine-payload — generic dispatcher.write result for mfa-verify handler
80
+ const data = result.data;
81
+ const token = await mintSessionAndRespond(c, data.session);
82
+ const landingPath = landingPathFragment({
83
+ flow: "login",
84
+ roles: data.session.roles,
85
+ tenantId: data.session.tenantId,
86
+ });
87
+ if (rateLimiter) {
88
+ await rateLimiter.reset(clientIp);
89
+ }
90
+ return c.json({
91
+ isSuccess: true,
92
+ token,
93
+ user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
94
+ ...landingPath,
95
+ });
96
+ });
97
+ }
98
+ }
99
+ export function registerMfaPreauthEnableStartRoute(ctx) {
100
+ const { api, dispatcher, config, getClientIp } = ctx;
101
+ // POST /auth/mfa/preauth-enable-start — lets a user blocked at login by
102
+ // MFA enforcement (mfa-setup-required) generate a TOTP secret + QR
103
+ // without a session, using the preauthSetupToken login.write.ts issued.
104
+ // No JWT is minted here (unlike /auth/login and /auth/mfa/verify) — the
105
+ // handler's response carries a secret-bearing setupToken that a later
106
+ // pre-auth confirm step (the same shape auth-mfa's own enable-confirm
107
+ // consumes) verifies. Rate-limited like mfaVerifyRateLimit/
108
+ // mfaPreauthConfirmRateLimit — a preauthSetupToken is valid and
109
+ // not-single-use for its whole TTL, so without this cap a replay is a
110
+ // memory-hard CPU amplifier (see AuthRoutesConfig doc comment).
111
+ if (config.mfaPreauthEnableStartHandler) {
112
+ const mfaPreauthEnableStartQn = config.mfaPreauthEnableStartHandler;
113
+ const statusMap = config.mfaPreauthEnableStartErrorStatusMap ?? {};
114
+ const rateLimiter = config.mfaPreauthEnableStartRateLimit === null
115
+ ? null
116
+ : (config.mfaPreauthEnableStartRateLimit ?? inMemoryLoginRateLimiterFallback());
117
+ api.post(Routes.authMfaPreauthEnableStart, async (c) => {
118
+ const raw = await c.req.json().catch(() => null);
119
+ const parsed = MfaPreauthEnableStartBody.safeParse(raw);
120
+ if (!parsed.success) {
121
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
122
+ }
123
+ const body = parsed.data;
124
+ const clientIp = getClientIp(c);
125
+ if (rateLimiter) {
126
+ const allowed = await rateLimiter.check(clientIp);
127
+ if (!allowed) {
128
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
129
+ }
130
+ // Second axis, independent of the IP-derived bucket above: the
131
+ // preauthSetupToken is minted by login.write.ts and not freely
132
+ // choosable, so this cap survives an attacker rotating
133
+ // x-forwarded-for to bypass the IP bucket (kumiko-framework#1522).
134
+ const tokenAllowed = await rateLimiter.check(preauthTokenKeyOf(body.preauthSetupToken));
135
+ if (!tokenAllowed) {
136
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
137
+ }
138
+ }
139
+ const result = await dispatcher.write(mfaPreauthEnableStartQn, body, createAnonymousUser(SYSTEM_TENANT_ID));
140
+ if (!result.isSuccess) {
141
+ // @cast-boundary error-details — KumikoError.details shape is per-error
142
+ const reason = result.error.details?.reason ?? result.error.code;
143
+ // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
144
+ const status = (statusMap[reason] ?? result.error.httpStatus);
145
+ return c.json({ isSuccess: false, error: result.error }, status);
146
+ }
147
+ // @cast-boundary engine-payload — generic dispatcher.write result for
148
+ // the preauth-enable-start handler
149
+ const data = result.data;
150
+ return c.json({ isSuccess: true, ...data });
151
+ });
152
+ }
153
+ }
154
+ export function registerMfaPreauthConfirmRoute(ctx) {
155
+ const { api, dispatcher, config, getClientIp, mintSessionAndRespond, landingPathFragment } = ctx;
156
+ // POST /auth/mfa/preauth-confirm — completes both the enrollment started
157
+ // by preauth-enable-start AND the login mfa-setup-required blocked.
158
+ // Mirrors /auth/mfa/verify structurally (public, anonymous identity dispatch,
159
+ // mintSessionAndRespond on success) with its OWN rate limiter
160
+ // (mfaPreauthConfirmRateLimit) — same reasoning as mfaVerifyRateLimit.
161
+ // Per-account brute-force protection (capping wrong-code guesses against
162
+ // one still-valid setupToken) is owned by the handler itself — see
163
+ // AuthRoutesConfig.mfaPreauthConfirmHandler's doc comment.
164
+ if (config.mfaPreauthConfirmHandler) {
165
+ const mfaPreauthConfirmQn = config.mfaPreauthConfirmHandler;
166
+ const statusMap = config.mfaPreauthConfirmErrorStatusMap ?? {};
167
+ const rateLimiter = config.mfaPreauthConfirmRateLimit === null
168
+ ? null
169
+ : (config.mfaPreauthConfirmRateLimit ?? inMemoryLoginRateLimiterFallback());
170
+ api.post(Routes.authMfaPreauthConfirm, async (c) => {
171
+ const raw = await c.req.json().catch(() => null);
172
+ const parsed = MfaPreauthConfirmBody.safeParse(raw);
173
+ if (!parsed.success) {
174
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
175
+ }
176
+ const body = parsed.data;
177
+ const clientIp = getClientIp(c);
178
+ if (rateLimiter) {
179
+ const allowed = await rateLimiter.check(clientIp);
180
+ if (!allowed) {
181
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
182
+ }
183
+ }
184
+ const result = await dispatcher.write(mfaPreauthConfirmQn, body, createAnonymousUser(SYSTEM_TENANT_ID));
185
+ if (!result.isSuccess) {
186
+ // @cast-boundary error-details — KumikoError.details shape is per-error
187
+ const reason = result.error.details?.reason ?? result.error.code;
188
+ // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
189
+ const status = (statusMap[reason] ?? result.error.httpStatus);
190
+ return c.json({ isSuccess: false, error: result.error }, status);
191
+ }
192
+ // @cast-boundary engine-payload — generic dispatcher.write result for mfa-preauth-confirm handler
193
+ const data = result.data;
194
+ const token = await mintSessionAndRespond(c, data.session);
195
+ const landingPath = landingPathFragment({
196
+ flow: "login",
197
+ roles: data.session.roles,
198
+ tenantId: data.session.tenantId,
199
+ });
200
+ if (rateLimiter) {
201
+ await rateLimiter.reset(clientIp);
202
+ }
203
+ return c.json({
204
+ isSuccess: true,
205
+ token,
206
+ user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
207
+ ...landingPath,
208
+ });
209
+ });
210
+ }
211
+ }
@@ -0,0 +1,2 @@
1
+ import type { AuthRouteContext } from "./auth-routes-context.ts";
2
+ export declare function registerSessionRoutes(ctx: AuthRouteContext): void;
@@ -0,0 +1,144 @@
1
+ import { createSystemUser } from "../engine/system-user.js";
2
+ import { assertUnreachable } from "../utils/index.js";
3
+ import { Routes } from "./api-constants.js";
4
+ import { getUser } from "./auth-middleware.js";
5
+ import { isUnknownHandlerError } from "./auth-routes-shared.js";
6
+ import { clearSessionCookies, getSessionCookies } from "./session-cookies.js";
7
+ function switchTenantRejectionResponse(c, reason) {
8
+ switch (reason) {
9
+ case "not_a_member":
10
+ return c.json({ error: "not_a_member" }, 403);
11
+ case "principal_blocked":
12
+ return c.json({ error: "principal_blocked" }, 403);
13
+ case "tenant_teardown":
14
+ return c.json({ error: "tenant_unavailable" }, 410);
15
+ default:
16
+ return assertUnreachable(reason, "ActiveMembershipRejection");
17
+ }
18
+ }
19
+ // A missing, malformed or non-object body reads as {} so callers answer with their own 400.
20
+ async function readJsonObjectOrEmpty(req) {
21
+ const raw = await req.json().catch(() => null);
22
+ return raw !== null && typeof raw === "object" ? raw : {};
23
+ }
24
+ function tenantIndependentPrefs(user) {
25
+ return {
26
+ ...(user.timezone ? { timezone: user.timezone } : {}),
27
+ ...(user.locale ? { locale: user.locale } : {}),
28
+ };
29
+ }
30
+ function isNonEmptyString(value) {
31
+ return typeof value === "string" && value.length > 0;
32
+ }
33
+ export function registerSessionRoutes(ctx) {
34
+ const { api, dispatcher, config, sessionCookies, switchTenantMfaGate, resolveSwitchTenantRoles, mintSessionAndRespond, } = ctx;
35
+ // POST /auth/logout — revokes the current session. Requires a valid JWT so
36
+ // the middleware has already populated `user.sid` from the `jti` claim. If
37
+ // the app hasn't wired a sessionRevoker, logout is effectively a no-op on
38
+ // the server — the client can just drop the token.
39
+ api.post(Routes.authLogout, async (c) => {
40
+ const user = getUser(c);
41
+ if (config.sessionRevoker && user.sid) {
42
+ await config.sessionRevoker(user.sid);
43
+ }
44
+ // Clear cookies on the cookie-transport path. Idempotent — clearing a
45
+ // missing cookie is a no-op, so bearer-only clients aren't affected.
46
+ clearSessionCookies(c, getSessionCookies(c, sessionCookies));
47
+ return c.json({ isSuccess: true });
48
+ });
49
+ // GET /auth/tenants — list tenants the current user belongs to
50
+ api.get(Routes.authTenants, async (c) => {
51
+ const user = getUser(c);
52
+ try {
53
+ // System-scoped: membershipQuery is access-locked to system-role.
54
+ // @cast-boundary engine-payload — generic dispatcher.query result
55
+ const memberships = (await dispatcher.query(config.membershipQuery, { userId: user.id }, createSystemUser(user.tenantId)));
56
+ return c.json({
57
+ tenants: memberships.map((m) => ({
58
+ tenantId: m.tenantId,
59
+ roles: m.roles,
60
+ ...(m.tenantName !== undefined && { name: m.tenantName }),
61
+ ...(m.tenantKey !== undefined && { key: m.tenantKey }),
62
+ })),
63
+ activeTenantId: user.tenantId,
64
+ });
65
+ }
66
+ catch (e) {
67
+ // Only legitimate fallback: the app hasn't wired membershipQuery at
68
+ // all. A DB fault or a permission failure has to bubble up so ops
69
+ // sees it — collapsing them into "just your current tenant" hides
70
+ // outages behind a UI that looks fine.
71
+ if (!isUnknownHandlerError(e))
72
+ throw e;
73
+ return c.json({
74
+ tenants: [{ tenantId: user.tenantId, roles: [...user.roles] }],
75
+ activeTenantId: user.tenantId,
76
+ });
77
+ }
78
+ });
79
+ // POST /auth/switch-tenant — switch to a different tenant
80
+ api.post(Routes.authSwitchTenant, async (c) => {
81
+ const user = getUser(c);
82
+ const body = await readJsonObjectOrEmpty(c.req);
83
+ // The id seeds a SYSTEM identity in resolveActiveMembership — reject anything
84
+ // that is not a non-empty string before it gets that far.
85
+ if (!isNonEmptyString(body.tenantId)) {
86
+ return c.json({ error: "invalid_tenant" }, 400);
87
+ }
88
+ const targetTenantId = body.tenantId; // @cast-boundary request-body
89
+ if (targetTenantId === user.tenantId) {
90
+ return c.json({ error: "already_in_tenant" }, 400);
91
+ }
92
+ // Membership + principal-blocked + tenant-teardown in one building
93
+ // block — trusted server code asking on the user's behalf, same trust boundary as before.
94
+ let activeMembership;
95
+ try {
96
+ activeMembership = await dispatcher.resolveActiveMembership(user.id, targetTenantId);
97
+ }
98
+ catch (e) {
99
+ // No membershipQuery wired → switching tenants is just not offered in
100
+ // this deployment. Any other error propagates so a broken query handler
101
+ // surfaces as a real 5xx instead of a misleading 400.
102
+ if (!isUnknownHandlerError(e))
103
+ throw e;
104
+ return c.json({ error: "tenant_switch_not_available" }, 400);
105
+ }
106
+ if (activeMembership.kind === "rejected") {
107
+ return switchTenantRejectionResponse(c, activeMembership.reason);
108
+ }
109
+ const membership = activeMembership.membership;
110
+ const gate = await switchTenantMfaGate(c, user, targetTenantId);
111
+ if (gate !== undefined && "response" in gate)
112
+ return gate.response;
113
+ const sessionRoles = gate !== undefined ? gate.clearedRoles : await resolveSwitchTenantRoles(user, membership);
114
+ // Issue new JWT with the target tenant and its roles. Claims MUST be
115
+ // recomputed for the new tenant — stale claims from the previous
116
+ // tenant would leak identity facts across tenancies (e.g. teamId from
117
+ // tenant A accidentally surviving into tenant B's session). The
118
+ // resolver runs each feature's r.authClaims() hook under the new
119
+ // TenantDb scope.
120
+ const targetSession = {
121
+ id: user.id,
122
+ tenantId: targetTenantId,
123
+ roles: sessionRoles,
124
+ // Tenant-independent prefs must survive the switch (fw#2343).
125
+ ...tenantIndependentPrefs(user),
126
+ };
127
+ const claims = await dispatcher.resolveAuthClaims(targetSession);
128
+ const sessionForJwt = Object.keys(claims).length > 0 ? { ...targetSession, claims } : targetSession;
129
+ // Session rotation: revoke old sid BEFORE creating the new one so a
130
+ // creator failure leaves the user logged-out cleanly rather than with
131
+ // two live sessions. Client must log in again on creator-throw. A
132
+ // revoker/creator that actually throws (Redis down, DB deadlock) surfaces
133
+ // as a 5xx — swallowing it into tenant_switch_not_available was hiding
134
+ // real outages.
135
+ if (config.sessionRevoker && user.sid) {
136
+ await config.sessionRevoker(user.sid);
137
+ }
138
+ // Session creation + JWT sign + cookies — see mintSessionAndRespond. A
139
+ // fresh csrfToken is minted (inside the helper) so a compromised csrf-
140
+ // value can't cross a tenant boundary.
141
+ const newToken = await mintSessionAndRespond(c, sessionForJwt);
142
+ return c.json({ token: newToken, tenantId: targetTenantId, roles: sessionRoles });
143
+ });
144
+ }
@@ -0,0 +1,33 @@
1
+ import type { Context, Hono } from "hono";
2
+ import * as z from "zod";
3
+ import type { Dispatcher } from "../pipeline/dispatcher.ts";
4
+ export type PublicAuthErrorStatus = 400 | 401 | 403 | 422 | 429 | 500;
5
+ export declare const SignupRequestBody: z.ZodObject<{
6
+ email: z.ZodEmail;
7
+ handover: z.ZodOptional<z.ZodObject<{
8
+ entityType: z.ZodString;
9
+ token: z.ZodString;
10
+ }, z.core.$strip>>;
11
+ }, z.core.$strip>;
12
+ export declare function isUnknownHandlerError(e: unknown): boolean;
13
+ export type PendingMfaStep = {
14
+ kind: "mfa-challenge";
15
+ challengeToken: string;
16
+ } | {
17
+ kind: "mfa-setup-required";
18
+ preauthSetupToken: string;
19
+ };
20
+ export declare function pendingMfaStepResponse(c: Context, step: PendingMfaStep, landing?: {
21
+ readonly landingPath?: string;
22
+ }): Response;
23
+ export declare function registerTokenRequestRoute(opts: {
24
+ api: Hono;
25
+ dispatcher: Dispatcher;
26
+ path: string;
27
+ requestHandler: string;
28
+ schema?: typeof RequestTokenBody | typeof SignupRequestBody;
29
+ }): void;
30
+ declare const RequestTokenBody: z.ZodObject<{
31
+ email: z.ZodEmail;
32
+ }, z.core.$strip>;
33
+ export {};
@@ -0,0 +1,81 @@
1
+ import * as z from "zod";
2
+ import { createAnonymousUser } from "../engine/system-user.js";
3
+ import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
4
+ import { NotFoundError } from "../errors/index.js";
5
+ // Cross-device try-first handover (kumiko-framework#3035 follow-up,
6
+ // offlot-app#454): an optional tenant-handover grant the signup-request
7
+ // route forwards to the handler for server-side verification and binding —
8
+ // the grant itself never appears anywhere else (URL, mail, log, response).
9
+ export const SignupRequestBody = z.object({
10
+ email: z.email(),
11
+ handover: z
12
+ .object({
13
+ entityType: z.string().min(1).max(200),
14
+ token: z.string().min(1).max(4096),
15
+ })
16
+ .optional(),
17
+ });
18
+ // Shape guard for "handler not registered" — the only legitimate reason to
19
+ // fall back to a single-tenant reply on /auth/tenants or /auth/switch-tenant.
20
+ // Every other error (DB down, revoker throws, access denied, …) has to
21
+ // propagate — otherwise we'd silently paper over outages.
22
+ export function isUnknownHandlerError(e) {
23
+ if (!(e instanceof NotFoundError))
24
+ return false;
25
+ // @cast-boundary error-details — KumikoError.details shape is per-error
26
+ const details = e.details;
27
+ return details?.entity === "handler";
28
+ }
29
+ export function pendingMfaStepResponse(c, step, landing = {}) {
30
+ if (step.kind === "mfa-setup-required") {
31
+ return c.json({
32
+ isSuccess: true,
33
+ mfaSetupRequired: true,
34
+ preauthSetupToken: step.preauthSetupToken,
35
+ ...landing,
36
+ });
37
+ }
38
+ return c.json({
39
+ isSuccess: true,
40
+ mfaRequired: true,
41
+ challengeToken: step.challengeToken,
42
+ ...landing,
43
+ });
44
+ }
45
+ // --- shared route builders for token flows ---------------------------------
46
+ // Password-reset, email-verification and signup share the exact same request
47
+ // HTTP-shape: parse body → dispatch the request handler → always-200 (no
48
+ // enumeration). The handler mints the token AND dispatches the magic-link mail
49
+ // via delivery (ctx.notify); the route never sees the token. confirm-route
50
+ // validates the token + does the state change → typed failure or 200. Keeping
51
+ // the silent-success invariant in one place means changing how the framework
52
+ // handles "invalid_body" on a public token endpoint is one edit, not three.
53
+ export function registerTokenRequestRoute(opts) {
54
+ const body = opts.schema ?? RequestTokenBody;
55
+ opts.api.post(opts.path, async (c) => {
56
+ const raw = await c.req.json().catch(() => null);
57
+ const parsed = body.safeParse(raw);
58
+ // Malformed body → silent success. A probing client mustn't learn
59
+ // anything from the shape of their input.
60
+ if (!parsed.success)
61
+ return c.json({ isSuccess: true });
62
+ // The handler dispatches the magic-link mail via delivery before returning.
63
+ // Handler-level failures (misconfiguration, e.g. no RateLimitResolver) stay
64
+ // out of the response so it is uniform for unknown vs. known emails, but
65
+ // they are logged — otherwise the mail silently never goes out. No email in
66
+ // the log line (PII).
67
+ const result = await opts.dispatcher.write(opts.requestHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
68
+ // rate_limited is an expected, attacker-triggerable outcome; logging it
69
+ // would flood the sink and bury real misconfiguration errors.
70
+ if (!result.isSuccess && result.error.code !== "rate_limited") {
71
+ console.error(`[kumiko] token request handler "${opts.requestHandler}" failed: ${result.error.code}`);
72
+ }
73
+ return c.json({ isSuccess: true });
74
+ });
75
+ }
76
+ // Shared request-body shape for request-password-reset + request-email-
77
+ // verification. Extracted so the two flows stay in sync when the schema
78
+ // gains optional fields (locale, deviceId, …).
79
+ const RequestTokenBody = z.object({
80
+ email: z.email(),
81
+ });
@@ -0,0 +1,2 @@
1
+ import type { AuthRouteContext } from "./auth-routes-context.ts";
2
+ export declare function registerSignupRoutes(ctx: AuthRouteContext): void;
@@ -0,0 +1,75 @@
1
+ import * as z from "zod";
2
+ import { createAnonymousUser } from "../engine/system-user.js";
3
+ import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
4
+ import { Routes } from "./api-constants.js";
5
+ import { pendingMfaStepResponse, registerTokenRequestRoute, SignupRequestBody, } from "./auth-routes-shared.js";
6
+ const SignupConfirmBody = z.object({
7
+ token: z.string().min(1),
8
+ password: z.string().min(8).max(200),
9
+ });
10
+ export function registerSignupRoutes(ctx) {
11
+ const { api, dispatcher, config, mintSessionAndRespond, landingPathFragment } = ctx;
12
+ // Signup confirm mints the session (auto-login) unless the MFA gate applies;
13
+ // then it answers with the MFA step only, no session or cookies.
14
+ if (config.signup) {
15
+ const sg = config.signup;
16
+ registerTokenRequestRoute({
17
+ api,
18
+ dispatcher,
19
+ path: Routes.authSignupRequest,
20
+ requestHandler: sg.requestHandler,
21
+ schema: SignupRequestBody,
22
+ });
23
+ api.post(Routes.authSignupConfirm, async (c) => {
24
+ const raw = await c.req.json().catch(() => null);
25
+ const parsed = SignupConfirmBody.safeParse(raw);
26
+ if (!parsed.success) {
27
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
28
+ }
29
+ const result = await dispatcher.write(sg.confirmHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
30
+ if (!result.isSuccess) {
31
+ // 422 for invalid_signup_token (handler-level UnprocessableError).
32
+ // @cast-boundary engine-payload — KumikoError.httpStatus narrows to the http-status union
33
+ const status = result.error.httpStatus;
34
+ return c.json({ isSuccess: false, error: result.error }, status);
35
+ }
36
+ // @cast-boundary engine-payload — generic dispatcher.write result for signup-confirm
37
+ const data = result.data;
38
+ if (data.kind !== "auth-session") {
39
+ // No session yet, but the account and a claimed handover exist: hand
40
+ // the signup landing to the client so the login that follows lands
41
+ // where a signup without the MFA gate would have.
42
+ return pendingMfaStepResponse(c, data, landingPathFragment({ flow: "signup", ...data.signup }));
43
+ }
44
+ // Session creation + JWT sign + cookies — see mintSessionAndRespond.
45
+ const token = await mintSessionAndRespond(c, data.session);
46
+ const signupOutcome = {
47
+ roles: data.session.roles,
48
+ tenantId: data.session.tenantId,
49
+ tenantKey: data.tenantKey,
50
+ ...(data.handover !== undefined && { handover: data.handover }),
51
+ };
52
+ const landingPath = landingPathFragment({ flow: "signup", ...signupOutcome });
53
+ return c.json({
54
+ isSuccess: true,
55
+ token,
56
+ user: {
57
+ id: data.session.id,
58
+ tenantId: data.session.tenantId,
59
+ roles: data.session.roles,
60
+ },
61
+ // tenantKey for the post-signup redirect to /<tenantKey>/. Unlike the
62
+ // login response, which only needs `tenants[]` (the user picks in the
63
+ // switcher), signup knows exactly ONE membership — the frontend UI uses
64
+ // it directly as the redirect target.
65
+ tenantKey: data.tenantKey,
66
+ // Cross-device try-first handover (kumiko-framework#3035 follow-up):
67
+ // present only when signup-confirm actually claimed a bound grant —
68
+ // absent, not null, so a client that doesn't know the field sees
69
+ // nothing unusual.
70
+ ...(data.handover !== undefined && { handover: data.handover }),
71
+ ...landingPath,
72
+ });
73
+ });
74
+ }
75
+ }