@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.
- package/agents/examples/custom-fields-basic.md +13 -3
- package/agents/examples/designer-demo.md +6 -2
- package/agents/examples/i18n.md +36 -1
- package/agents/features.md +4 -0
- package/dist/api/auth-rate-limiters.d.ts +8 -0
- package/dist/api/auth-rate-limiters.js +105 -0
- package/dist/api/auth-routes-context.d.ts +34 -0
- package/dist/api/auth-routes-context.js +192 -0
- package/dist/api/auth-routes-invite.d.ts +2 -0
- package/dist/api/auth-routes-invite.js +171 -0
- package/dist/api/auth-routes-login.d.ts +2 -0
- package/dist/api/auth-routes-login.js +103 -0
- package/dist/api/auth-routes-mfa.d.ts +4 -0
- package/dist/api/auth-routes-mfa.js +211 -0
- package/dist/api/auth-routes-session.d.ts +2 -0
- package/dist/api/auth-routes-session.js +144 -0
- package/dist/api/auth-routes-shared.d.ts +33 -0
- package/dist/api/auth-routes-shared.js +81 -0
- package/dist/api/auth-routes-signup.d.ts +2 -0
- package/dist/api/auth-routes-signup.js +75 -0
- package/dist/api/auth-routes-token-flows.d.ts +4 -0
- package/dist/api/auth-routes-token-flows.js +91 -0
- package/dist/api/auth-routes-types.d.ts +76 -0
- package/dist/api/auth-routes-types.js +1 -0
- package/dist/api/auth-routes.d.ts +5 -85
- package/dist/api/auth-routes.js +20 -1095
- package/dist/config/index.d.ts +1 -1
- package/dist/crypto/pii-ciphertext-format.js +1 -1
- package/dist/db/blind-index-cleanup.js +1 -1
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +9 -0
- package/dist/db/distinct-on.js +28 -0
- package/dist/db/event-store-executor-write.js +2 -2
- package/dist/db/migrate-runner.js +4 -4
- package/dist/db/pg-error.js +0 -12
- package/dist/db/queries/event-consumer.js +3 -3
- package/dist/db/queries/postgres-array-literal.js +2 -2
- package/dist/db/query.js +48 -20
- package/dist/db/tenant-db.js +1 -1
- package/dist/engine/boot-validator/i18n-keys.d.ts +2 -0
- package/dist/engine/boot-validator/i18n-keys.js +15 -1
- package/dist/engine/boot-validator/index.d.ts +4 -0
- package/dist/engine/boot-validator/index.js +3 -1
- package/dist/engine/boot-validator/pii-retention.js +6 -7
- package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
- package/dist/engine/boot-validator/screens-actions.js +488 -0
- package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
- package/dist/engine/boot-validator/screens-dashboard.js +467 -0
- package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
- package/dist/engine/boot-validator/screens-forms.js +516 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
- package/dist/engine/boot-validator/screens-lists.js +319 -0
- package/dist/engine/boot-validator/screens.d.ts +4 -21
- package/dist/engine/boot-validator/screens.js +26 -1799
- package/dist/engine/build-app-schema.js +1 -1
- package/dist/engine/factories.d.ts +3 -3
- package/dist/engine/steps/read-find-one.js +1 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/file-ref-entity.d.ts +6 -126
- package/dist/files/file-ref-table.d.ts +6 -126
- package/dist/files/file-routes.js +39 -16
- package/dist/i18n/required-surface-keys.d.ts +22 -2
- package/dist/i18n/required-surface-keys.js +91 -3
- package/dist/jobs/job-runner-helpers.d.ts +45 -0
- package/dist/jobs/job-runner-helpers.js +281 -0
- package/dist/jobs/job-runner-schedulers.d.ts +23 -0
- package/dist/jobs/job-runner-schedulers.js +106 -0
- package/dist/jobs/job-runner-types.d.ts +65 -0
- package/dist/jobs/job-runner-types.js +2 -0
- package/dist/jobs/job-runner.d.ts +4 -75
- package/dist/jobs/job-runner.js +7 -384
- package/dist/pipeline/dispatch-shared.js +2 -2
- package/dist/pipeline/event-dispatcher-admin.d.ts +15 -8
- package/dist/pipeline/event-dispatcher-admin.js +28 -7
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +3 -4
- package/dist/pipeline/index.d.ts +2 -2
- package/dist/pipeline/index.js +1 -1
- package/dist/schema-cli.js +5 -0
- package/package.json +3 -3
- package/src/changes.json +39 -0
|
@@ -0,0 +1,171 @@
|
|
|
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 { toKumikoError, toWriteErrorInfo } from "../errors/index.js";
|
|
5
|
+
import { Routes } from "./api-constants.js";
|
|
6
|
+
import { getUser } from "./auth-middleware.js";
|
|
7
|
+
import { pendingMfaStepResponse } from "./auth-routes-shared.js";
|
|
8
|
+
const InviteAcceptBody = z.object({
|
|
9
|
+
token: z.string().min(1),
|
|
10
|
+
});
|
|
11
|
+
const InviteAcceptWithLoginBody = z.object({
|
|
12
|
+
token: z.string().min(1),
|
|
13
|
+
email: z.string().email(),
|
|
14
|
+
password: z.string().min(8).max(200),
|
|
15
|
+
});
|
|
16
|
+
const InviteSignupCompleteBody = z.object({
|
|
17
|
+
token: z.string().min(1),
|
|
18
|
+
password: z.string().min(8).max(200),
|
|
19
|
+
});
|
|
20
|
+
const InviteInfoBody = z.object({
|
|
21
|
+
token: z.string().min(1),
|
|
22
|
+
});
|
|
23
|
+
export function registerInviteRoutes(ctx) {
|
|
24
|
+
const { api, dispatcher, config, mintSessionAndRespond, landingPathFragment } = ctx;
|
|
25
|
+
// Tenant-Invite magic link. 3 separate routes for 3 accept branches:
|
|
26
|
+
if (config.invite) {
|
|
27
|
+
const inv = config.invite;
|
|
28
|
+
// Branch 1: a logged-in user clicks the invite link → membership add in
|
|
29
|
+
// the invited tenant (NOT a tenant switch — the user stays in their
|
|
30
|
+
// current session and can switch later via the tenant switcher).
|
|
31
|
+
// Requires JWT (see PUBLIC_API_PATHS — invite-accept is NOT public,
|
|
32
|
+
// unlike acceptWithLogin/signupComplete).
|
|
33
|
+
api.post(Routes.authInviteAccept, async (c) => {
|
|
34
|
+
const user = getUser(c);
|
|
35
|
+
const raw = await c.req.json().catch(() => null);
|
|
36
|
+
const parsed = InviteAcceptBody.safeParse(raw);
|
|
37
|
+
if (!parsed.success) {
|
|
38
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
39
|
+
}
|
|
40
|
+
const result = await dispatcher.write(inv.acceptHandler, parsed.data, user);
|
|
41
|
+
if (!result.isSuccess) {
|
|
42
|
+
// @cast-boundary engine-payload — KumikoError.httpStatus
|
|
43
|
+
const status = result.error.httpStatus;
|
|
44
|
+
return c.json({ isSuccess: false, error: result.error }, status);
|
|
45
|
+
}
|
|
46
|
+
// @cast-boundary engine-payload — generic dispatcher.write result
|
|
47
|
+
const data = result.data;
|
|
48
|
+
const landingPath = landingPathFragment({
|
|
49
|
+
flow: "invite",
|
|
50
|
+
roles: [data.role],
|
|
51
|
+
tenantId: data.tenantId,
|
|
52
|
+
});
|
|
53
|
+
return c.json({
|
|
54
|
+
isSuccess: true,
|
|
55
|
+
tenantId: data.tenantId,
|
|
56
|
+
role: data.role,
|
|
57
|
+
alreadyMember: data.alreadyMember,
|
|
58
|
+
...landingPath,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
// Branch 2: anonymous user with an existing email — login + accept in a
|
|
62
|
+
// single round trip. JWT mint analogous to signup-confirm.
|
|
63
|
+
api.post(Routes.authInviteAcceptWithLogin, async (c) => {
|
|
64
|
+
const raw = await c.req.json().catch(() => null);
|
|
65
|
+
const parsed = InviteAcceptWithLoginBody.safeParse(raw);
|
|
66
|
+
if (!parsed.success) {
|
|
67
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
68
|
+
}
|
|
69
|
+
const result = await dispatcher.write(inv.acceptWithLoginHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
|
|
70
|
+
if (!result.isSuccess) {
|
|
71
|
+
const status = result.error.httpStatus; // @cast-boundary engine-payload
|
|
72
|
+
return c.json({ isSuccess: false, error: result.error }, status);
|
|
73
|
+
}
|
|
74
|
+
// @cast-boundary engine-payload — same three-shape union as /auth/login
|
|
75
|
+
// (see gateEnforceMfa): a straight session, an MFA challenge, or a
|
|
76
|
+
// hard mfa-setup-required block. Only the auth-session branch also
|
|
77
|
+
// carries tenantId/role (invite-specific).
|
|
78
|
+
const data = result.data;
|
|
79
|
+
if (data.kind === "mfa-setup-required") {
|
|
80
|
+
return c.json({
|
|
81
|
+
isSuccess: true,
|
|
82
|
+
mfaSetupRequired: true,
|
|
83
|
+
preauthSetupToken: data.preauthSetupToken,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (data.kind === "mfa-challenge") {
|
|
87
|
+
return c.json({ isSuccess: true, mfaRequired: true, challengeToken: data.challengeToken });
|
|
88
|
+
}
|
|
89
|
+
const token = await mintSessionAndRespond(c, data.session);
|
|
90
|
+
const landingPath = landingPathFragment({
|
|
91
|
+
flow: "invite",
|
|
92
|
+
roles: data.session.roles,
|
|
93
|
+
tenantId: data.tenantId,
|
|
94
|
+
});
|
|
95
|
+
return c.json({
|
|
96
|
+
isSuccess: true,
|
|
97
|
+
token,
|
|
98
|
+
user: {
|
|
99
|
+
id: data.session.id,
|
|
100
|
+
tenantId: data.session.tenantId,
|
|
101
|
+
roles: data.session.roles,
|
|
102
|
+
},
|
|
103
|
+
tenantId: data.tenantId,
|
|
104
|
+
role: data.role,
|
|
105
|
+
...landingPath,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
// Branch 3: anonymous user with a new email — user + membership are created,
|
|
109
|
+
// NO new tenant. JWT mint.
|
|
110
|
+
api.post(Routes.authInviteSignupComplete, async (c) => {
|
|
111
|
+
const raw = await c.req.json().catch(() => null);
|
|
112
|
+
const parsed = InviteSignupCompleteBody.safeParse(raw);
|
|
113
|
+
if (!parsed.success) {
|
|
114
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
115
|
+
}
|
|
116
|
+
const result = await dispatcher.write(inv.signupCompleteHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
|
|
117
|
+
if (!result.isSuccess) {
|
|
118
|
+
const status = result.error.httpStatus; // @cast-boundary engine-payload
|
|
119
|
+
return c.json({ isSuccess: false, error: result.error }, status);
|
|
120
|
+
}
|
|
121
|
+
// @cast-boundary engine-payload — same three-shape union as invite-accept-with-login
|
|
122
|
+
const data = result.data;
|
|
123
|
+
if (data.kind !== "auth-session")
|
|
124
|
+
return pendingMfaStepResponse(c, data);
|
|
125
|
+
const token = await mintSessionAndRespond(c, data.session);
|
|
126
|
+
const landingPath = landingPathFragment({
|
|
127
|
+
flow: "invite",
|
|
128
|
+
roles: data.session.roles,
|
|
129
|
+
tenantId: data.tenantId,
|
|
130
|
+
});
|
|
131
|
+
return c.json({
|
|
132
|
+
isSuccess: true,
|
|
133
|
+
token,
|
|
134
|
+
user: {
|
|
135
|
+
id: data.session.id,
|
|
136
|
+
tenantId: data.session.tenantId,
|
|
137
|
+
roles: data.session.roles,
|
|
138
|
+
},
|
|
139
|
+
tenantId: data.tenantId,
|
|
140
|
+
role: data.role,
|
|
141
|
+
...landingPath,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
// Anonymous, read-only invite lookup for the acceptance page: pre-fill
|
|
145
|
+
// the invited email and pick Branch 2 (existing account) vs Branch 3
|
|
146
|
+
// (new account) before the user submits. Token is never consumed here.
|
|
147
|
+
if (inv.infoHandler) {
|
|
148
|
+
const infoHandler = inv.infoHandler;
|
|
149
|
+
api.post(Routes.authInviteInfo, async (c) => {
|
|
150
|
+
const raw = await c.req.json().catch(() => null);
|
|
151
|
+
const parsed = InviteInfoBody.safeParse(raw);
|
|
152
|
+
if (!parsed.success) {
|
|
153
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
// @cast-boundary engine-payload — generic dispatcher.query result
|
|
157
|
+
const data = (await dispatcher.query(infoHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID)));
|
|
158
|
+
return c.json({ isSuccess: true, ...data });
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
const err = toKumikoError(e);
|
|
162
|
+
// Same body shape as the write routes' `{ isSuccess: false, error:
|
|
163
|
+
// result.error }` (WriteErrorInfo) — this is a query, not a write,
|
|
164
|
+
// so there's no WriteResult to read `.error` off; toWriteErrorInfo
|
|
165
|
+
// builds the equivalent shape directly from the caught error.
|
|
166
|
+
return c.json({ isSuccess: false, error: toWriteErrorInfo(err) }, err.httpStatus);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -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,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
|
+
}
|