@cosmicdrift/kumiko-bundled-features 0.297.0 → 0.304.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 (40) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/auth.integration.test.ts +2 -2
  3. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +5 -5
  4. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +2 -2
  5. package/src/auth-email-password/__tests__/login-anonymous-identity-rate-limit.integration.test.ts +124 -0
  6. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +6 -6
  7. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +28 -0
  8. package/src/auth-email-password/changes.json +6 -0
  9. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +2 -1
  10. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  11. package/src/auth-email-password/handlers/invite-accept.write.ts +3 -3
  12. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  13. package/src/auth-email-password/handlers/login.write.ts +6 -4
  14. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  16. package/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  18. package/src/auth-email-password/handlers/verify-email.write.ts +2 -1
  19. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +6 -9
  20. package/src/auth-mfa/__tests__/verify.integration.test.ts +6 -6
  21. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +3 -2
  22. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +4 -2
  23. package/src/auth-mfa/handlers/verify.write.ts +3 -2
  24. package/src/auth-mfa/mfa-status-checker.ts +2 -2
  25. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +11 -37
  26. package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +159 -119
  27. package/src/billing-foundation/__tests__/webhook-handler.integration.test.ts +174 -0
  28. package/src/billing-foundation/changes.json +12 -0
  29. package/src/billing-foundation/feature.ts +3 -3
  30. package/src/billing-foundation/handlers/process-event.write.ts +1 -1
  31. package/src/billing-foundation/index.ts +2 -4
  32. package/src/billing-foundation/subscription-tier-sync.ts +110 -83
  33. package/src/billing-foundation/webhook-handler.ts +117 -187
  34. package/src/inbound-mail-foundation/__tests__/connect-routes.integration.test.ts +52 -81
  35. package/src/inbound-mail-foundation/changes.json +6 -0
  36. package/src/inbound-mail-foundation/connect-routes.ts +218 -197
  37. package/src/inbound-mail-foundation/index.ts +1 -2
  38. package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +7 -40
  39. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +18 -60
  40. package/src/billing-foundation/__tests__/webhook-handler.test.ts +0 -200
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.297.0",
3
+ "version": "0.304.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -133,12 +133,12 @@
133
133
  "./workflow-runner": "./src/workflow-runner/index.ts"
134
134
  },
135
135
  "dependencies": {
136
- "@cosmicdrift/kumiko-dispatcher-live": "0.297.0",
137
- "@cosmicdrift/kumiko-framework": "0.297.0",
138
- "@cosmicdrift/kumiko-headless": "0.297.0",
139
- "@cosmicdrift/kumiko-renderer": "0.297.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.297.0",
141
- "@cosmicdrift/kumiko-types": "0.297.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.304.0",
137
+ "@cosmicdrift/kumiko-framework": "0.304.0",
138
+ "@cosmicdrift/kumiko-headless": "0.304.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.304.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.304.0",
141
+ "@cosmicdrift/kumiko-types": "0.304.0",
142
142
  "@mollie/api-client": "^4.5.0",
143
143
  "@node-rs/argon2": "^2.0.2",
144
144
  "@types/mailparser": "^3.4.6",
@@ -167,8 +167,8 @@
167
167
  ],
168
168
  "devDependencies": {
169
169
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.297.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.297.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.304.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.304.0",
172
172
  "jsqr": "^1.4.0"
173
173
  }
174
174
  }
@@ -393,12 +393,12 @@ describe("scenario 6: logout", () => {
393
393
  });
394
394
 
395
395
  test("unauthenticated call to logout is rejected by framework access", async () => {
396
- // roles: ["all"] — no authenticated role. Handler's access is
396
+ // roles: ["anonymous"] — no authenticated role. Handler's access is
397
397
  // access.authenticated which requires User/Admin/SystemAdmin.
398
398
  const guest = createTestUser({
399
399
  id: 0,
400
400
  tenantId: "00000000-0000-4000-8000-000000000000",
401
- roles: ["all"],
401
+ roles: ["anonymous"],
402
402
  });
403
403
  const error = await stack.http.writeErr(AuthHandlers.logout, {}, guest);
404
404
  expectErrorIncludes(error, "access_denied");
@@ -166,14 +166,14 @@ function aliceSession(): SessionUser {
166
166
  return { id: aliceId, tenantId: TENANT_A_ID, roles: ["Admin"] };
167
167
  }
168
168
 
169
- // auth-mfa:write:verify runs pre-session (access: { roles: ["all"] }) —
169
+ // auth-mfa:write:verify runs pre-session (access: { roles: ["anonymous"] }) —
170
170
  // dispatched here the same way the framework's /api/auth/mfa/verify route
171
- // would, with a guest identity. The handler derives everything it needs
172
- // from the challenge token, not from this actor.
171
+ // would, with the anonymous identity. The handler derives everything it
172
+ // needs from the challenge token, not from this actor.
173
173
  const GUEST: SessionUser = {
174
- id: "00000000-0000-0000-0000-000000000000",
174
+ id: "anonymous",
175
175
  tenantId: "00000000-0000-4000-8000-000000000000" as TenantId,
176
- roles: ["all"],
176
+ roles: ["anonymous"],
177
177
  };
178
178
 
179
179
  async function inviteEmail(email: string, role: string): Promise<string> {
@@ -82,9 +82,9 @@ function newTenantId(_suffix: string): TenantId {
82
82
  }
83
83
 
84
84
  const GUEST: SessionUser = {
85
- id: "00000000-0000-0000-0000-000000000000",
85
+ id: "anonymous",
86
86
  tenantId: SYSTEM_TENANT_ID,
87
- roles: ["all"],
87
+ roles: ["anonymous"],
88
88
  };
89
89
 
90
90
  function extractTokenFromMail(html: string): string {
@@ -0,0 +1,124 @@
1
+ // login.write.ts's access.roles: ["anonymous"] carries a mandatory
2
+ // handler-level rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 }
3
+ // (boot-validator's validateAnonymousRateLimit). Proves that L3 handler
4
+ // rate-limit fires over real HTTP, and that a legitimate login still succeeds.
5
+ //
6
+ // loginRateLimit is disabled (null): the route's own in-memory limiter
7
+ // buckets on "ip|email" and would trip before the handler-level "ip+handler"
8
+ // bucket under the same IP — this isolates the handler-level rateLimit.
9
+
10
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
+ import { randomBytes } from "node:crypto";
12
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import {
14
+ setupTestStack,
15
+ type TestStack,
16
+ TestUsers,
17
+ unsafeCreateEntityTable,
18
+ unsafePushTables,
19
+ } from "@cosmicdrift/kumiko-framework/stack";
20
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
21
+ import { createConfigFeature } from "../../config";
22
+ import { createConfigResolver } from "../../config/resolver";
23
+ import { configValuesTable } from "../../config/table";
24
+ import { hashPassword } from "../../shared";
25
+ import { createTenantFeature } from "../../tenant";
26
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
27
+ import { tenantEntity } from "../../tenant/schema/tenant";
28
+ import { seedTenantMembership } from "../../tenant/testing";
29
+ import { UserHandlers } from "../../user";
30
+ import { createUserFeature } from "../../user/feature";
31
+ import { userEntity, userTable } from "../../user/schema/user";
32
+ import { AuthErrors, AuthHandlers } from "../constants";
33
+ import { createAuthEmailPasswordFeature } from "../feature";
34
+
35
+ let stack: TestStack;
36
+ const encryptionKey = randomBytes(32).toString("base64");
37
+
38
+ beforeAll(async () => {
39
+ const encryption = createTestEnvelopeCipher(encryptionKey);
40
+ const resolver = createConfigResolver({ cipher: encryption });
41
+
42
+ stack = await setupTestStack({
43
+ features: [
44
+ createConfigFeature(),
45
+ createUserFeature(),
46
+ createTenantFeature(),
47
+ createAuthEmailPasswordFeature(),
48
+ ],
49
+ extraContext: { configResolver: resolver, configEncryption: encryption },
50
+ authConfig: {
51
+ membershipQuery: "tenant:query:memberships",
52
+ loginHandler: AuthHandlers.login,
53
+ loginRateLimit: null,
54
+ loginErrorStatusMap: {
55
+ [AuthErrors.invalidCredentials]: 401,
56
+ [AuthErrors.noMembership]: 403,
57
+ },
58
+ },
59
+ });
60
+
61
+ await stack.redis.flushNamespace();
62
+ await unsafeCreateEntityTable(stack.db, userEntity);
63
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
64
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
65
+ });
66
+
67
+ afterAll(async () => {
68
+ await stack.cleanup();
69
+ });
70
+
71
+ beforeEach(async () => {
72
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
73
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
74
+ await stack.redis.flushNamespace();
75
+ });
76
+
77
+ const systemAdmin = TestUsers.systemAdmin;
78
+
79
+ async function seedLoginUser(email: string, password: string): Promise<void> {
80
+ const hash = await hashPassword(password);
81
+ const created = await stack.http.writeOk<{ id: string }>(
82
+ UserHandlers.create,
83
+ { email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
84
+ systemAdmin,
85
+ );
86
+ await seedTenantMembership(stack.db, {
87
+ userId: created.id,
88
+ tenantId: "00000000-0000-4000-8000-000000000001",
89
+ roles: ["User"],
90
+ });
91
+ }
92
+
93
+ async function postLogin(ip: string, email: string, password: string): Promise<Response> {
94
+ return await stack.app.request("/api/auth/login", {
95
+ method: "POST",
96
+ headers: { "Content-Type": "application/json", "x-forwarded-for": ip },
97
+ body: JSON.stringify({ email, password }),
98
+ });
99
+ }
100
+
101
+ describe("POST /auth/login — anonymous identity handler rateLimit (ip+handler)", () => {
102
+ test("21st failed login from the same IP is rejected with rate_limited (429)", async () => {
103
+ const ip = "10.60.0.1";
104
+ for (let i = 0; i < 20; i++) {
105
+ const res = await postLogin(ip, `nope-${i}@example.com`, "wrong-password");
106
+ expect(res.status).toBe(401);
107
+ }
108
+ const last = await postLogin(ip, "nope-20@example.com", "wrong-password");
109
+ expect(last.status).toBe(429);
110
+ const body = (await last.json()) as { error?: { code?: string } | string };
111
+ const code = typeof body.error === "string" ? body.error : body.error?.code;
112
+ expect(code).toBe("rate_limited");
113
+ });
114
+
115
+ test("a legitimate login still succeeds (anonymous dispatch identity didn't break the happy path)", async () => {
116
+ const ip = "10.60.0.2";
117
+ await seedLoginUser("legit@example.com", "correct horse battery staple");
118
+
119
+ const res = await postLogin(ip, "legit@example.com", "correct horse battery staple");
120
+ expect(res.status).toBe(200);
121
+ const body = (await res.json()) as { isSuccess: boolean };
122
+ expect(body.isSuccess).toBe(true);
123
+ });
124
+ });
@@ -47,14 +47,14 @@ const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
47
47
  const TENANT_ID: TenantId = testTenantId(400);
48
48
 
49
49
  // auth-mfa:write:enable-start-preauth / enable-confirm-preauth both run
50
- // pre-session (access: { roles: ["all"] }) — dispatched here the same way
51
- // the framework's /api/auth/mfa/preauth-enable-start and preauth-confirm
52
- // routes would, with a guest identity. Both handlers derive everything they
53
- // need from the verified token, not from this actor.
50
+ // pre-session (access: { roles: ["anonymous"] }) — dispatched here the same
51
+ // way the framework's /api/auth/mfa/preauth-enable-start and preauth-confirm
52
+ // routes would, with the anonymous identity. Both handlers derive everything
53
+ // they need from the verified token, not from this actor.
54
54
  const GUEST: SessionUser = {
55
- id: "00000000-0000-0000-0000-000000000000",
55
+ id: "anonymous",
56
56
  tenantId: "00000000-0000-4000-8000-000000000000" as TenantId,
57
- roles: ["all"],
57
+ roles: ["anonymous"],
58
58
  };
59
59
 
60
60
  beforeAll(async () => {
@@ -10,6 +10,7 @@ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
10
  import {
11
11
  setupTestStack,
12
12
  type TestStack,
13
+ TestUsers,
13
14
  unsafeCreateEntityTable,
14
15
  unsafePushTables,
15
16
  } from "@cosmicdrift/kumiko-framework/stack";
@@ -22,10 +23,12 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
22
23
  import { notificationPreferencesTable } from "../../delivery/tables";
23
24
  import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
24
25
  import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
26
+ import { hashPassword } from "../../shared";
25
27
  import { createTemplateResolverFeature } from "../../template-resolver/feature";
26
28
  import { createTenantFeature } from "../../tenant";
27
29
  import { tenantMembershipsTable } from "../../tenant/membership-table";
28
30
  import { tenantEntity } from "../../tenant/schema/tenant";
31
+ import { UserHandlers } from "../../user";
29
32
  import { createUserFeature } from "../../user/feature";
30
33
  import { userEntity, userTable } from "../../user/schema/user";
31
34
  import { AuthHandlers } from "../constants";
@@ -174,3 +177,28 @@ describe("L2 rate-limit covers public token routes", () => {
174
177
  expect(victimFirst.status).toBe(200);
175
178
  });
176
179
  });
180
+
181
+ // The 200-for-unknown-email tests above prove nothing about the handler
182
+ // actually running — registerTokenRequestRoute swallows every handler
183
+ // failure into an always-200 response (anti-enumeration). Only a seeded,
184
+ // real user distinguishes "mail sent" from "silently swallowed" — which is
185
+ // exactly what an anonymous-dispatch switch could silently break.
186
+ describe("request-password-reset actually dispatches mail for a real user", () => {
187
+ test("a seeded user gets the reset mail (anonymous dispatch didn't swallow it)", async () => {
188
+ const hash = await hashPassword("correct horse battery staple");
189
+ await stack.http.writeOk(
190
+ UserHandlers.create,
191
+ { email: "reset-target@example.com", passwordHash: hash, displayName: "reset-target" },
192
+ TestUsers.systemAdmin,
193
+ );
194
+
195
+ const before = emailTransport.sent.length;
196
+ const res = await postFrom("/api/auth/request-password-reset", "10.50.0.200", {
197
+ email: "reset-target@example.com",
198
+ });
199
+
200
+ expect(res.status).toBe(200);
201
+ expect(emailTransport.sent.length).toBe(before + 1);
202
+ expect(emailTransport.sent.at(-1)?.to).toBe("reset-target@example.com");
203
+ });
204
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.300.0",
4
+ "type": "breaking",
5
+ "title": "auth handlers switch from GUEST_USER (roles: [\"all\"]) to the anonymous identity with a per-IP rateLimit",
6
+ "migration": "The auth-email-password and auth-mfa handlers reachable from unauthenticated /auth/* routes (login, invite-accept-with-login, invite-signup-complete, reset-password, signup-confirm, verify-email, confirm-account-unlock, signup-request, the token-request handlers, mfa verify, mfa enable-start-preauth, mfa enable-confirm-preauth) now declare access: { roles: [\"anonymous\"] } instead of access: { roles: [\"all\"] }, each with its own rateLimit: { per: \"ip+handler\", limit, windowSeconds }. Enforcing that rateLimit on a request that carries a client IP requires a RateLimitResolver (Redis), same as the already rate-limited self-registration-status query; without one these handlers fail with InternalError (fail-closed, #1467). The password-reset, email-verification and account-unlock request routes keep answering 200 for anti-enumeration, so there the failure shows up as a \"[kumiko] token request handler ... failed\" error log line and no mail is sent. Wire Redis (or context.rateLimit) before relying on these routes. Test fixtures that hand-roll a guest SessionUser with roles: [\"all\"] must switch to roles: [\"anonymous\"] (or createAnonymousUser from @cosmicdrift/kumiko-framework/engine)."
7
+ },
2
8
  {
3
9
  "version": "0.291.0",
4
10
  "type": "improvement",
@@ -29,7 +29,8 @@ export function createConfirmAccountUnlockHandler(opts: ConfirmAccountUnlockOpti
29
29
  schema: z.object({
30
30
  token: z.string().min(1),
31
31
  }),
32
- access: { roles: ["all"] },
32
+ access: { roles: ["anonymous"] },
33
+ rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
33
34
  agent: { expose: false },
34
35
  handler: async (event, ctx) => {
35
36
  if (!opts.hmacSecret) {
@@ -115,7 +115,8 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
115
115
  >({
116
116
  name: "invite-accept-with-login",
117
117
  schema: InviteAcceptWithLoginSchema,
118
- access: { roles: ["all"] },
118
+ access: { roles: ["anonymous"] },
119
+ rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
119
120
  escapeHatch: {
120
121
  reason:
121
122
  "Anonymous invite-accept has no session in the invited tenant yet — checks existing " +
@@ -78,9 +78,9 @@ export function createInviteAcceptHandler() {
78
78
  return defineWriteHandler<"invite-accept", typeof InviteAcceptSchema, InviteAcceptData>({
79
79
  name: "invite-accept",
80
80
  schema: InviteAcceptSchema,
81
- // openToAll: any authenticated user (Branch 1). Branch 2+3 (anon)
82
- // nutzen `roles: ["all"]` weil dort GUEST_USER mit ["all"]-role
83
- // dispatched wird.
81
+ // openToAll: any authenticated user (Branch 1). Branch 2+3 (anon) live
82
+ // in the sibling invite-accept-with-login / invite-signup-complete
83
+ // handlers, which declare roles: ["anonymous"] instead.
84
84
  access: {
85
85
  openToAll: {
86
86
  reason:
@@ -92,7 +92,8 @@ export function createInviteSignupCompleteHandler() {
92
92
  >({
93
93
  name: "invite-signup-complete",
94
94
  schema: InviteSignupCompleteSchema,
95
- access: { roles: ["all"] },
95
+ access: { roles: ["anonymous"] },
96
+ rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
96
97
  agent: { expose: false },
97
98
  escapeHatch: {
98
99
  reason: INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON,
@@ -257,9 +257,10 @@ export async function gateBuildSession(
257
257
  }
258
258
 
259
259
  // Login — unauthenticated entry point. The route is wired public (no JWT
260
- // middleware), synthesising a guest SessionUser for the handler's access
261
- // check. Everything inside the handler goes through ctx.queryAs(system, ...)
262
- // so the user feature stays the single owner of its table.
260
+ // middleware), dispatching with the framework's anonymous identity for the
261
+ // handler's access check. Everything inside the handler goes through
262
+ // ctx.queryAs(system, ...) so the user feature stays the single owner of its
263
+ // table.
263
264
  export function createLoginHandler(opts: LoginHandlerOptions = {}) {
264
265
  const strictVerification = opts.strictEmailVerification === true;
265
266
  const maxFailedAttempts =
@@ -273,7 +274,8 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
273
274
  email: z.email(),
274
275
  password: z.string().min(1),
275
276
  }),
276
- access: { roles: ["all"] },
277
+ access: { roles: ["anonymous"] },
278
+ rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
277
279
  escapeHatch: {
278
280
  reason:
279
281
  "Unauthenticated login has no caller identity yet — it looks up the user row by " +
@@ -25,7 +25,8 @@ export function createResetPasswordHandler(opts: ResetPasswordOptions) {
25
25
  token: z.string().min(1),
26
26
  newPassword: passwordSchema,
27
27
  }),
28
- access: { roles: ["all"] },
28
+ access: { roles: ["anonymous"] },
29
+ rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
29
30
  escapeHatch: {
30
31
  reason:
31
32
  "Anonymous token holder has no session — runConfirmTokenFlow loads and updates the " +
@@ -67,7 +67,8 @@ export function createSignupConfirmHandler() {
67
67
  return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
68
68
  name: "signup-confirm",
69
69
  schema: SignupConfirmSchema,
70
- access: { roles: ["all"] },
70
+ access: { roles: ["anonymous"] },
71
+ rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
71
72
  agent: { expose: false },
72
73
  escapeHatch: {
73
74
  reason: SIGNUP_CONFIRM_PROVISION_REASON,
@@ -75,7 +75,8 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
75
75
  return defineWriteHandler<"signup-request", typeof SignupRequestSchema, SignupRequestData>({
76
76
  name: "signup-request",
77
77
  schema: SignupRequestSchema,
78
- access: { roles: ["all"] },
78
+ access: { roles: ["anonymous"] },
79
+ rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
79
80
  description:
80
81
  "Starts magic-link self-registration by mailing a fresh activation link to an address and invalidating any link still outstanding for it; the answer looks the same whether or not the address is already registered.",
81
82
  handler: async (event, ctx) => {
@@ -94,7 +94,8 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
94
94
  return defineWriteHandler<TName, typeof RequestTokenSchema, TokenRequestData<TSuccessKind>>({
95
95
  name: spec.handlerName,
96
96
  schema: RequestTokenSchema,
97
- access: { roles: ["all"] },
97
+ access: { roles: ["anonymous"] },
98
+ rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
98
99
  escapeHatch: {
99
100
  reason:
100
101
  "Anonymous requester has no session — looks up the target user by email via " +
@@ -26,7 +26,8 @@ export function createVerifyEmailHandler(opts: VerifyEmailOptions) {
26
26
  return defineWriteHandler<"verify-email", typeof VerifyEmailSchema, VerifyEmailData>({
27
27
  name: "verify-email",
28
28
  schema: VerifyEmailSchema,
29
- access: { roles: ["all"] },
29
+ access: { roles: ["anonymous"] },
30
+ rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
30
31
  escapeHatch: {
31
32
  reason:
32
33
  "Anonymous token holder has no session — runConfirmTokenFlow loads and updates the " +
@@ -14,6 +14,7 @@ import {
14
14
  createTenantDb,
15
15
  } from "@cosmicdrift/kumiko-framework/db";
16
16
  import {
17
+ createAnonymousUser,
17
18
  type SessionUser,
18
19
  SYSTEM_TENANT_ID,
19
20
  type TenantId,
@@ -45,14 +46,10 @@ const CHALLENGE_TOKEN_SECRET = "no-redis-test-challenge-token-secret-not-real-00
45
46
  const SETUP_TOKEN_SECRET = "no-redis-test-setup-token-secret-not-real-0002";
46
47
  const TENANT_ID: TenantId = testTenantId(421);
47
48
 
48
- // Mirrors framework's auth-routes.ts GUEST_USER — verify/enable-confirm-preauth
49
- // are guest-callable (access: { roles: ["all"] }), the real identity comes
49
+ // Mirrors framework's auth-routes.ts anonymous dispatch — verify/enable-confirm-preauth
50
+ // are guest-callable (access: { roles: ["anonymous"] }), the real identity comes
50
51
  // entirely from the signed token, not from this dispatch-level user.
51
- const GUEST_USER: SessionUser = {
52
- id: "00000000-0000-0000-0000-000000000000",
53
- tenantId: SYSTEM_TENANT_ID,
54
- roles: ["all"],
55
- };
52
+ const ANONYMOUS_USER: SessionUser = createAnonymousUser(SYSTEM_TENANT_ID);
56
53
 
57
54
  beforeAll(async () => {
58
55
  const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
@@ -125,7 +122,7 @@ describe("auth-mfa handlers — ctx.redis unset (#1467)", () => {
125
122
  const err = await stack.http.writeErr(
126
123
  AuthMfaHandlers.verify,
127
124
  { challengeToken: token, code: "123456" },
128
- GUEST_USER,
125
+ ANONYMOUS_USER,
129
126
  );
130
127
  expect(err.httpStatus).toBeGreaterThanOrEqual(500);
131
128
  expect(err.code).toBe("internal_error");
@@ -148,7 +145,7 @@ describe("auth-mfa handlers — ctx.redis unset (#1467)", () => {
148
145
  const err = await stack.http.writeErr(
149
146
  AuthMfaHandlers.enableConfirmPreauth,
150
147
  { setupToken: token, code: "123456" },
151
- GUEST_USER,
148
+ ANONYMOUS_USER,
152
149
  );
153
150
  expect(err.httpStatus).toBeGreaterThanOrEqual(500);
154
151
  expect(err.code).toBe("internal_error");
@@ -44,14 +44,14 @@ let stack: TestStack;
44
44
  const SETUP_TOKEN_SECRET = "test-mfa-setup-secret-at-least-32-bytes-long!!";
45
45
  const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
46
46
 
47
- // Handler access is `{ roles: ["all"] }`, matching how the framework route
48
- // dispatches with a guest identity — a literal here is enough since the
49
- // handler derives everything from the challenge-token payload, not from
50
- // event.user.
47
+ // Handler access is `{ roles: ["anonymous"] }`, matching how the framework
48
+ // route dispatches with the anonymous identity — a literal here is enough
49
+ // since the handler derives everything from the challenge-token payload,
50
+ // not from event.user.
51
51
  const GUEST: SessionUser = {
52
- id: "00000000-0000-0000-0000-000000000000",
52
+ id: "anonymous",
53
53
  tenantId: "00000000-0000-4000-8000-000000000001" as TenantId,
54
- roles: ["all"],
54
+ roles: ["anonymous"],
55
55
  };
56
56
 
57
57
  beforeAll(async () => {
@@ -63,7 +63,8 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
63
63
  setupToken: z.string().min(1),
64
64
  code: z.string().length(6),
65
65
  }),
66
- access: { roles: ["all"] },
66
+ access: { roles: ["anonymous"] },
67
+ rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
67
68
  escapeHatch: {
68
69
  reason:
69
70
  "Pre-auth MFA enrollment step has no session yet — re-checks status via ctx.queryAs(SYSTEM, " +
@@ -86,7 +87,7 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
86
87
  if (tenantId === undefined) return invalidSetupToken();
87
88
 
88
89
  // Fail closed: unlike verify.write.ts/enable-confirm.write.ts, this
89
- // endpoint has NO gate before it (access: { roles: ["all"] }, no JWT,
90
+ // endpoint has NO gate before it (access: { roles: ["anonymous"] }, no JWT,
90
91
  // no prior challenge-token from a successful login) — its only secret
91
92
  // is a 6-digit TOTP code. Silently skipping the brute-force cap and
92
93
  // single-use burn when ctx.redis is absent would let the setup token
@@ -29,7 +29,8 @@ export type EnableStartPreauthOptions = {
29
29
  // preauthSetupToken (minted by login.write.ts when enforcement policy
30
30
  // blocks an unenrolled user), not from event.user — there is no session at
31
31
  // this point. Runs pre-session the same way verify.write.ts does (dispatched
32
- // by the framework's /auth/mfa/preauth-enable-start route with GUEST_USER).
32
+ // by the framework's /auth/mfa/preauth-enable-start route with the
33
+ // anonymous identity).
33
34
  export function createEnableStartPreauthHandler(opts: EnableStartPreauthOptions) {
34
35
  return defineWriteHandler({
35
36
  name: "enable-start-preauth",
@@ -37,7 +38,8 @@ export function createEnableStartPreauthHandler(opts: EnableStartPreauthOptions)
37
38
  preauthSetupToken: z.string().min(1),
38
39
  accountLabel: z.string().min(1).max(200),
39
40
  }),
40
- access: { roles: ["all"] },
41
+ access: { roles: ["anonymous"] },
42
+ rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
41
43
  description:
42
44
  "Begins TOTP enrollment for a user whose sign-in was blocked because the tenant requires two-factor authentication, taking identity from the pre-auth token login issued instead of from a session.",
43
45
  // Same secret-bearing result as enable-start.
@@ -35,7 +35,7 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
35
35
  });
36
36
 
37
37
  // Completes the two-step login. Runs pre-session (dispatched by the
38
- // framework's /auth/mfa/verify route with a guest identity, same as
38
+ // framework's /auth/mfa/verify route with the anonymous identity, same as
39
39
  // login.write.ts) — everything it needs (which user, which tenant) comes
40
40
  // from the challenge token, not from an authenticated caller.
41
41
  export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
@@ -45,7 +45,8 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
45
45
  return defineWriteHandler({
46
46
  name: "verify",
47
47
  schema: z.object({ challengeToken: z.string().min(1), code: z.string().min(6).max(9) }),
48
- access: { roles: ["all"] },
48
+ access: { roles: ["anonymous"] },
49
+ rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
49
50
  escapeHatch: {
50
51
  reason:
51
52
  "Pre-auth MFA step has no session yet — re-derives it via ctx.queryAs(SYSTEM, " +
@@ -65,8 +65,8 @@ export function createMfaStatusChecker(opts: {
65
65
  return { required: true, challengeToken: token };
66
66
  }
67
67
 
68
- // ctx.config is bound to the CALLING user's tenant (GUEST_USER for
69
- // login) — wrong tenant here. ctx.configResolver takes tenantId
68
+ // ctx.config is bound to the CALLING user's tenant (the anonymous
69
+ // identity's tenant for login) — wrong tenant here. ctx.configResolver takes tenantId
70
70
  // explicitly, which is what a pre-session check against the LOGGING-IN
71
71
  // user's tenant needs.
72
72
  // @cast-boundary engine-payload — "select" config values are validated