@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.160.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 (163) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
  3. package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
  4. package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
  5. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
  7. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
  8. package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
  9. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
  10. package/src/auth-email-password/constants.ts +15 -0
  11. package/src/auth-email-password/email-templates.ts +32 -0
  12. package/src/auth-email-password/errors.ts +9 -0
  13. package/src/auth-email-password/feature.ts +37 -5
  14. package/src/auth-email-password/handlers/change-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
  17. package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
  19. package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
  20. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  21. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  22. package/src/auth-email-password/i18n.ts +42 -0
  23. package/src/auth-email-password/index.ts +2 -0
  24. package/src/auth-email-password/lockout-store.ts +10 -5
  25. package/src/auth-email-password/password-policy.ts +2108 -0
  26. package/src/auth-email-password/signed-token.ts +1 -0
  27. package/src/auth-email-password/unlock-token.ts +28 -0
  28. package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
  29. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  30. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
  31. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
  32. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
  33. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
  34. package/src/auth-email-password/web/auth-client.ts +33 -0
  35. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  36. package/src/auth-email-password/web/index.ts +6 -0
  37. package/src/auth-email-password/web/login-screen.tsx +15 -0
  38. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  39. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  40. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  41. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  42. package/src/auth-foundation/boot-checks.ts +91 -0
  43. package/src/auth-foundation/feature.ts +103 -0
  44. package/src/auth-foundation/index.ts +23 -0
  45. package/src/auth-foundation/types.ts +107 -0
  46. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  47. package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
  48. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  49. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  50. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  52. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  53. package/src/channel-email/pii-guard.ts +6 -3
  54. package/src/config/__tests__/app-overrides.test.ts +11 -0
  55. package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
  56. package/src/config/__tests__/cascade.integration.test.ts +39 -0
  57. package/src/config/__tests__/config.integration.test.ts +14 -0
  58. package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
  59. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  61. package/src/config/feature.ts +2 -4
  62. package/src/config/handlers/set.write.ts +7 -1
  63. package/src/config/resolver.ts +1 -1
  64. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  65. package/src/delivery/handlers/log.query.ts +1 -1
  66. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  67. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  68. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  69. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  70. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  71. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
  72. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  73. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  74. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
  75. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  76. package/src/inbound-provider-inmemory/feature.ts +2 -2
  77. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  78. package/src/jobs/handlers/detail.query.ts +1 -1
  79. package/src/jobs/handlers/list.query.ts +1 -1
  80. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  81. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  82. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  83. package/src/managed-pages/handlers/branding.query.ts +5 -1
  84. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  85. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  86. package/src/personal-access-tokens/feature.ts +23 -8
  87. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  88. package/src/personal-access-tokens/index.ts +0 -14
  89. package/src/personal-access-tokens/resolver.ts +2 -2
  90. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  91. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  92. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  93. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  94. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  95. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  96. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  97. package/src/sessions/__tests__/test-helpers.ts +15 -2
  98. package/src/sessions/feature.ts +31 -3
  99. package/src/sessions/handlers/detail.query.ts +2 -2
  100. package/src/sessions/handlers/list.query.ts +4 -2
  101. package/src/sessions/handlers/mine.query.ts +4 -2
  102. package/src/sessions/testing.ts +14 -1
  103. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  104. package/src/shared/decrypt-stored-pii.ts +12 -5
  105. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  106. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  107. package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
  108. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  109. package/src/tenant/handlers/invitations.query.ts +4 -2
  110. package/src/tenant/handlers/members.query.ts +2 -2
  111. package/src/tenant/seeding.ts +2 -2
  112. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  113. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  114. package/src/tenant-lifecycle/feature.ts +7 -0
  115. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  116. package/src/tier-engine/feature.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +20 -0
  118. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  120. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  122. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  123. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  125. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  129. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  131. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  135. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  136. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  137. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  138. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  139. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  140. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  141. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  142. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
  143. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  144. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  145. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  146. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  147. package/src/user-data-rights/boot-checks.ts +86 -0
  148. package/src/user-data-rights/feature.ts +116 -6
  149. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  150. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  151. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  152. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  153. package/src/user-data-rights/run-export-jobs.ts +3 -3
  154. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  155. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
  156. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  157. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  159. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  160. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  161. package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
  162. package/src/user-profile/handlers/change-email.write.ts +1 -1
  163. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
3
4
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
5
  import {
5
6
  configureBlindIndexKey,
@@ -13,6 +14,7 @@ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
14
  import {
14
15
  setupTestStack,
15
16
  type TestStack,
17
+ TestUsers,
16
18
  testTenantId,
17
19
  unsafeCreateEntityTable,
18
20
  unsafePushTables,
@@ -65,6 +67,7 @@ beforeAll(async () => {
65
67
  createUserFeature(),
66
68
  createTenantFeature(),
67
69
  createAuthEmailPasswordFeature(),
70
+ authFoundationFeature,
68
71
  createSessionsFeature(),
69
72
  ],
70
73
  extraContext: { configResolver: resolver, configEncryption: encryption },
@@ -75,7 +78,7 @@ beforeAll(async () => {
75
78
  },
76
79
  });
77
80
  callbacks.set(createSessionCallbacks({ db: stack.db }));
78
- h = makeSessionHelpers(stack, TENANT);
81
+ h = makeSessionHelpers(stack, TENANT, bound.asAuthConfig().sessionCreator);
79
82
 
80
83
  await unsafeCreateEntityTable(stack.db, userEntity);
81
84
  await unsafeCreateEntityTable(stack.db, tenantEntity);
@@ -97,9 +100,10 @@ describe("sessions feature — login → check → revoke → rejected", () => {
97
100
  const { sid } = await h.login("persist@example.com", "pw-long-enough");
98
101
 
99
102
  const rows = await selectMany(stack.db, userSessionTable);
100
- expect(rows).toHaveLength(1);
101
- expect(rows[0]?.["id"]).toBe(sid);
102
- expect(rows[0]?.["revokedAt"]).toBeNull();
103
+ const userRows = rows.filter((r) => r["userId"] !== TestUsers.systemAdmin.id);
104
+ expect(userRows).toHaveLength(1);
105
+ expect(userRows[0]?.["id"]).toBe(sid);
106
+ expect(userRows[0]?.["revokedAt"]).toBeNull();
103
107
  });
104
108
 
105
109
  test("authenticated request passes while session is live, 401s once revoked", async () => {
@@ -138,8 +142,9 @@ describe("sessions feature — login → check → revoke → rejected", () => {
138
142
  expect(logoutRes.status).toBe(200);
139
143
 
140
144
  const rows = await selectMany(stack.db, userSessionTable);
141
- expect(rows[0]?.["id"]).toBe(sid);
142
- expect(rows[0]?.["revokedAt"]).not.toBeNull();
145
+ const row = rows.find((r) => r["id"] === sid);
146
+ expect(row?.["id"]).toBe(sid);
147
+ expect(row?.["revokedAt"]).not.toBeNull();
143
148
 
144
149
  const next = await h.authedPost("/api/query", token, {
145
150
  type: "user:query:user:me",
@@ -294,12 +299,13 @@ describe("sessions feature — login → check → revoke → rejected", () => {
294
299
  expect(body.error?.details?.reason).toBe("ownership_denied");
295
300
  });
296
301
 
297
- test("revoke-all-others on a sidless JWT refuses loudly (session_required)", async () => {
302
+ test("revoke-all-others on a sidless JWT is rejected by the middleware (no_sid)", async () => {
298
303
  const { userId } = await h.seedUser("sidless@example.com", "pw-long-enough");
299
304
 
300
- // Hand-sign a JWT without jti — simulates a stateless-JWT deployment
301
- // or a rolling-deploy gap. "sign out everywhere else" is ill-defined
302
- // without knowing which session is "current", so refuse.
305
+ // Hand-sign a JWT without jti. Once a sessionChecker is wired there is
306
+ // no relaxed mode (see session-strict-mode.integration.test.ts) — the
307
+ // middleware rejects it with no_sid before the handler's own
308
+ // session_required guard is ever reached.
303
309
  const tokenNoSid = await stack.jwt.sign({ id: userId, tenantId: TENANT, roles: ["User"] });
304
310
 
305
311
  const res = await stack.http.raw(
@@ -308,9 +314,9 @@ describe("sessions feature — login → check → revoke → rejected", () => {
308
314
  { type: SessionHandlers.revokeAllOthers, payload: {} },
309
315
  { Authorization: `Bearer ${tokenNoSid}` },
310
316
  );
311
- expect(res.status).toBe(422);
317
+ expect(res.status).toBe(401);
312
318
  const body = (await res.json()) as { error?: { details?: { reason?: string } } };
313
- expect(body.error?.details?.reason).toBe("session_required");
319
+ expect(body.error?.details?.reason).toBe("no_sid");
314
320
  });
315
321
 
316
322
  test("N concurrent revoke+logout requests on the same sid converge safely", async () => {
@@ -491,10 +497,13 @@ describe("sessions feature — login → check → revoke → rejected", () => {
491
497
  revokedAt: string | null;
492
498
  }>;
493
499
  };
494
- // Three rows total: Alice's pre-promotion session, Alice's post-promotion
495
- // session, Bob's session. Two distinct users.
496
- expect(body.data).toHaveLength(3);
497
- const userIds = new Set(body.data.map((r) => r.userId));
500
+ // Three rows total for the two test users: Alice's pre-promotion session,
501
+ // Alice's post-promotion session, Bob's session. seedUser's bootstrap
502
+ // actor (systemAdmin) also holds live sessions in this tenant — exclude
503
+ // them, they aren't part of what this test pins.
504
+ const nonSystemRows = body.data.filter((r) => r.userId !== TestUsers.systemAdmin.id);
505
+ expect(nonSystemRows).toHaveLength(3);
506
+ const userIds = new Set(nonSystemRows.map((r) => r.userId));
498
507
  expect(userIds.size).toBe(2);
499
508
 
500
509
  // Order: most-recently-created first. aliceAsAdmin's session was the
@@ -552,7 +561,9 @@ describe("sessions feature — login → check → revoke → rejected", () => {
552
561
  // different tenant must not be able to read another tenant's session rows.
553
562
  test("session:detail and session:list never leak sessions across tenants", async () => {
554
563
  const TENANT_2 = testTenantId(2);
555
- const h2 = makeSessionHelpers(stack, TENANT_2);
564
+ const h2 = makeSessionHelpers(stack, TENANT_2, (user, meta) =>
565
+ callbacks.get().sessionCreator(user, meta),
566
+ );
556
567
 
557
568
  const { userId: carolId } = await h.seedUser("carol4@example.com", "pw-long-enough");
558
569
  await updateRows(
@@ -9,12 +9,14 @@
9
9
  // const res = await h.authedPost("/api/query", token, { type, payload });
10
10
 
11
11
  import { expect } from "bun:test";
12
+ import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
12
13
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
14
  import { type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
14
15
  import * as jose from "jose";
15
16
  import { hashPassword } from "../../shared";
16
17
  import { seedTenantMembership } from "../../tenant/seeding";
17
18
  import { UserHandlers } from "../../user";
19
+ import { withMintedSession } from "../testing";
18
20
 
19
21
  export type LoginResult = {
20
22
  readonly token: string;
@@ -25,7 +27,15 @@ export type LoginResult = {
25
27
  // `ReturnType<typeof makeSessionHelpers>` — no separate type export to
26
28
  // keep in sync with the implementation. Params are typed inline on each
27
29
  // method so the inference is sharp.
28
- export function makeSessionHelpers(stack: TestStack, tenantId: TenantId) {
30
+ // `sessionCreator` is only needed when the suite wires a real sessionChecker
31
+ // (auth-middleware then rejects the sidless systemAdmin bootstrap actor
32
+ // used by seedUser). Suites without a sessionChecker (e.g. PAT tests) can
33
+ // omit it.
34
+ export function makeSessionHelpers(
35
+ stack: TestStack,
36
+ tenantId: TenantId,
37
+ sessionCreator?: SessionCreator,
38
+ ) {
29
39
  return {
30
40
  async seedUser(
31
41
  email: string,
@@ -33,10 +43,13 @@ export function makeSessionHelpers(stack: TestStack, tenantId: TenantId) {
33
43
  opts?: { roles?: readonly string[] },
34
44
  ): Promise<{ userId: string }> {
35
45
  const hash = await hashPassword(password);
46
+ const actor = sessionCreator
47
+ ? await withMintedSession(sessionCreator, TestUsers.systemAdmin)
48
+ : TestUsers.systemAdmin;
36
49
  const created = await stack.http.writeOk<{ id: string }>(
37
50
  UserHandlers.create,
38
51
  { email, passwordHash: hash, displayName: email.split("@")[0] ?? "u" },
39
- TestUsers.systemAdmin,
52
+ actor,
40
53
  );
41
54
  await seedTenantMembership(stack.db, {
42
55
  userId: created.id,
@@ -1,3 +1,8 @@
1
+ import {
2
+ EXT_SESSION_STORE,
3
+ type SessionStore,
4
+ type SessionStoreProvider,
5
+ } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
1
6
  import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
7
  import {
3
8
  access,
@@ -14,7 +19,11 @@ import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
14
19
  import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
15
20
  import { SESSIONS_I18N } from "./i18n";
16
21
  import { userSessionEntity } from "./schema/user-session";
17
- import type { SessionAllOthersRevoker, SessionMassRevoker } from "./session-callbacks";
22
+ import {
23
+ createSessionCallbacks,
24
+ type SessionAllOthersRevoker,
25
+ type SessionMassRevoker,
26
+ } from "./session-callbacks";
18
27
 
19
28
  export type SessionsFeatureOptions = {
20
29
  // A successful update on the `user` entity that changes the `passwordHash`
@@ -76,7 +85,7 @@ export function bindAutoRevokeFromFeature(
76
85
  export function createSessionsFeature(options?: SessionsFeatureOptions): FeatureDefinition {
77
86
  return defineFeature("sessions", (r) => {
78
87
  r.describe(
79
- "Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
88
+ "Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the same callbacks are also registered as an auth-foundation sessionStore provider, resolvable generically via `resolveSessionStore()`. The feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
80
89
  );
81
90
  r.uiHints({
82
91
  displayLabel: "Sessions · Server-side Logout",
@@ -86,7 +95,9 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
86
95
  // sessionChecker reads read_users on every authenticated request (status
87
96
  // gate for locked accounts) — make that a boot-time dependency so a
88
97
  // sessions-without-user wiring fails validateBoot instead of 500ing live.
89
- r.requires("user");
98
+ // auth-foundation owns EXT_SESSION_STORE, which the useExtension below
99
+ // registers against.
100
+ r.requires("user", "auth-foundation");
90
101
  // store_user_sessions is a hot-path direct-write store: sessionCreator
91
102
  // inserts and the revoke handlers update rows WITHOUT emitting lifecycle
92
103
  // events (the row columns ARE the audit trail). Registering it as
@@ -102,6 +113,23 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
102
113
  piiEncryptedOnWrite: true,
103
114
  });
104
115
 
116
+ // Self-registers as auth-foundation's sessionStore provider (#1371) —
117
+ // wraps the same createSessionCallbacks() used by the manual
118
+ // buildServer({ auth: { ... } }) wiring above; a future issue (#1372)
119
+ // removes that manual wiring once the middleware resolves generically
120
+ // via resolveSessionStore().
121
+ r.useExtension(EXT_SESSION_STORE, "default", {
122
+ build: (deps): SessionStore => {
123
+ const callbacks = createSessionCallbacks({ db: deps.db });
124
+ return {
125
+ creator: callbacks.sessionCreator,
126
+ revoker: callbacks.sessionRevoker,
127
+ checker: callbacks.sessionChecker,
128
+ massRevoker: callbacks.sessionMassRevoker,
129
+ };
130
+ },
131
+ } satisfies SessionStoreProvider);
132
+
105
133
  const handlers = {
106
134
  revoke: r.writeHandler(revokeWrite),
107
135
  revokeAllOthers: r.writeHandler(revokeAllOthersWrite),
@@ -27,9 +27,9 @@ export const detailQuery = defineQueryHandler({
27
27
  createdAt: row.createdAt,
28
28
  expiresAt: row.expiresAt,
29
29
  revokedAt: row.revokedAt,
30
- ip: row.ip ? await decryptStoredPii(row.ip, "sessions:detail") : row.ip,
30
+ ip: row.ip ? await decryptStoredPii(row.ip, "ip", "sessions:detail") : row.ip,
31
31
  userAgent: row.userAgent
32
- ? await decryptStoredPii(row.userAgent, "sessions:detail")
32
+ ? await decryptStoredPii(row.userAgent, "userAgent", "sessions:detail")
33
33
  : row.userAgent,
34
34
  };
35
35
  },
@@ -30,8 +30,10 @@ export const listQuery = defineQueryHandler({
30
30
  createdAt: r.createdAt,
31
31
  expiresAt: r.expiresAt,
32
32
  revokedAt: r.revokedAt,
33
- ip: r.ip ? await decryptStoredPii(r.ip, "sessions:list") : r.ip,
34
- userAgent: r.userAgent ? await decryptStoredPii(r.userAgent, "sessions:list") : r.userAgent,
33
+ ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:list") : r.ip,
34
+ userAgent: r.userAgent
35
+ ? await decryptStoredPii(r.userAgent, "userAgent", "sessions:list")
36
+ : r.userAgent,
35
37
  })),
36
38
  );
37
39
  },
@@ -32,8 +32,10 @@ export const mineQuery = defineQueryHandler({
32
32
  id: r.id,
33
33
  createdAt: r.createdAt,
34
34
  expiresAt: r.expiresAt,
35
- ip: r.ip ? await decryptStoredPii(r.ip, "sessions:mine") : r.ip,
36
- userAgent: r.userAgent ? await decryptStoredPii(r.userAgent, "sessions:mine") : r.userAgent,
35
+ ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:mine") : r.ip,
36
+ userAgent: r.userAgent
37
+ ? await decryptStoredPii(r.userAgent, "userAgent", "sessions:mine")
38
+ : r.userAgent,
37
39
  current: currentSid === r.id,
38
40
  })),
39
41
  );
@@ -17,7 +17,8 @@
17
17
  // sessions feature owns. framework/testing only provides the generic
18
18
  // `createLateBoundHolder<T>` — shape-independent.
19
19
 
20
- import type { AuthRoutesConfig } from "@cosmicdrift/kumiko-framework/api";
20
+ import type { AuthRoutesConfig, SessionCreator } from "@cosmicdrift/kumiko-framework/api";
21
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
21
22
  import type { LateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
22
23
  import type { SessionCallbacks, SessionMassRevoker } from "./session-callbacks";
23
24
 
@@ -40,3 +41,15 @@ export function sessionCallbacksFromLateBound(
40
41
  asMassRevoker: () => (userId) => holder.get().sessionMassRevoker(userId),
41
42
  };
42
43
  }
44
+
45
+ // Bootstrap actors (system-admin seed writes) need a real sid once a
46
+ // sessionChecker is wired — auth-middleware rejects sidless JWTs as
47
+ // forged/pre-session-tracking. Mints a live session row and returns the
48
+ // actor with `sid` attached so `stack.http.writeOk(..., actor)` passes.
49
+ export async function withMintedSession(
50
+ sessionCreator: SessionCreator,
51
+ user: SessionUser,
52
+ ): Promise<SessionUser> {
53
+ const sid = await sessionCreator(user, { ip: "127.0.0.1", userAgent: "test" });
54
+ return { ...user, sid };
55
+ }
@@ -0,0 +1,145 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { runChunkedMigration } from "../chunked-entity-migration";
3
+
4
+ describe("runChunkedMigration", () => {
5
+ test("processes all batches and stops with 'done' when nextBatch returns empty", async () => {
6
+ const batches: readonly number[][] = [[1, 2], [3], []];
7
+ let call = 0;
8
+ const migrated: number[] = [];
9
+
10
+ const result = await runChunkedMigration<number>({
11
+ nextBatch: async () => batches[call++] ?? [],
12
+ migrateRow: async (row) => {
13
+ migrated.push(row);
14
+ return "migrated";
15
+ },
16
+ maxFailures: 10,
17
+ deadlineAt: Number.POSITIVE_INFINITY,
18
+ });
19
+
20
+ expect(migrated).toEqual([1, 2, 3]);
21
+ expect(result).toEqual({
22
+ migrated: 3,
23
+ skipped: 0,
24
+ failed: 0,
25
+ batchesProcessed: 2,
26
+ stoppedReason: "done",
27
+ });
28
+ });
29
+
30
+ test("counts skipped and failed outcomes separately from migrated", async () => {
31
+ let call = 0;
32
+ const rows = [
33
+ { id: 1, outcome: "migrated" as const },
34
+ { id: 2, outcome: "skipped" as const },
35
+ { id: 3, outcome: "failed" as const },
36
+ ];
37
+
38
+ const result = await runChunkedMigration({
39
+ nextBatch: async () => (call++ === 0 ? rows : []),
40
+ migrateRow: async (row) => row.outcome,
41
+ maxFailures: 10,
42
+ deadlineAt: Number.POSITIVE_INFINITY,
43
+ });
44
+
45
+ expect(result.migrated).toBe(1);
46
+ expect(result.skipped).toBe(1);
47
+ expect(result.failed).toBe(1);
48
+ expect(result.stoppedReason).toBe("done");
49
+ });
50
+
51
+ test("a thrown migrateRow counts as failed and invokes onRowError", async () => {
52
+ let call = 0;
53
+ const errors: unknown[] = [];
54
+ const boom = new Error("boom");
55
+
56
+ const result = await runChunkedMigration<number>({
57
+ nextBatch: async () => (call++ === 0 ? [1] : []),
58
+ migrateRow: async () => {
59
+ throw boom;
60
+ },
61
+ maxFailures: 10,
62
+ deadlineAt: Number.POSITIVE_INFINITY,
63
+ onRowError: (row, err) => errors.push([row, err]),
64
+ });
65
+
66
+ expect(result.failed).toBe(1);
67
+ expect(errors).toEqual([[1, boom]]);
68
+ });
69
+
70
+ test("stops with 'too_many_failures' once failed >= maxFailures, mid-batch", async () => {
71
+ let call = 0;
72
+ const seen: number[] = [];
73
+
74
+ const result = await runChunkedMigration<number>({
75
+ nextBatch: async () => (call++ === 0 ? [1, 2, 3] : []),
76
+ migrateRow: async (row) => {
77
+ seen.push(row);
78
+ return "failed";
79
+ },
80
+ maxFailures: 2,
81
+ deadlineAt: Number.POSITIVE_INFINITY,
82
+ });
83
+
84
+ // The circuit-breaker checks before each row — row 3 is never attempted.
85
+ expect(seen).toEqual([1, 2]);
86
+ expect(result.failed).toBe(2);
87
+ expect(result.stoppedReason).toBe("too_many_failures");
88
+ });
89
+
90
+ test("stops with 'too_many_failures' checked between batches too", async () => {
91
+ let call = 0;
92
+ const batches = [["a"], ["b"], ["c"]];
93
+
94
+ const result = await runChunkedMigration<string>({
95
+ nextBatch: async () => batches[call++] ?? [],
96
+ migrateRow: async () => "failed",
97
+ maxFailures: 1,
98
+ deadlineAt: Number.POSITIVE_INFINITY,
99
+ });
100
+
101
+ // Batch 1 processes "a" and pushes failed to 1; the breaker is only
102
+ // checked at the TOP of the loop, so batch 2 is fetched (batchesProcessed
103
+ // becomes 2) before the breaker trips and its row is never touched.
104
+ expect(result.batchesProcessed).toBe(2);
105
+ expect(result.stoppedReason).toBe("too_many_failures");
106
+ });
107
+
108
+ test("stops with 'timeout' once the deadline has passed, without calling nextBatch again", async () => {
109
+ let nextBatchCalls = 0;
110
+
111
+ const result = await runChunkedMigration<number>({
112
+ nextBatch: async () => {
113
+ nextBatchCalls++;
114
+ return [];
115
+ },
116
+ migrateRow: async () => "migrated",
117
+ maxFailures: 10,
118
+ deadlineAt: Date.now() - 1,
119
+ });
120
+
121
+ expect(nextBatchCalls).toBe(0);
122
+ expect(result.batchesProcessed).toBe(0);
123
+ expect(result.stoppedReason).toBe("timeout");
124
+ });
125
+
126
+ test("stops with 'signal' when the AbortSignal is already aborted", async () => {
127
+ const controller = new AbortController();
128
+ controller.abort();
129
+ let nextBatchCalls = 0;
130
+
131
+ const result = await runChunkedMigration<number>({
132
+ nextBatch: async () => {
133
+ nextBatchCalls++;
134
+ return [];
135
+ },
136
+ migrateRow: async () => "migrated",
137
+ maxFailures: 10,
138
+ deadlineAt: Number.POSITIVE_INFINITY,
139
+ signal: controller.signal,
140
+ });
141
+
142
+ expect(nextBatchCalls).toBe(0);
143
+ expect(result.stoppedReason).toBe("signal");
144
+ });
145
+ });
@@ -6,14 +6,21 @@ import {
6
6
 
7
7
  // Raw reads (fetchOne/selectMany) return the stored column value — with an
8
8
  // active KMS that is ciphertext (self-describing: it names its own subject,
9
- // so no entity/field context is needed). An erased subject yields the
10
- // sentinel, which matches no real value.
11
- export async function decryptStoredPii(value: string, fallbackRequestId: string): Promise<string> {
9
+ // so no entity/field context is needed for key selection). `field` must
10
+ // still match the field name used at encrypt time — it is part of the
11
+ // AAD (#1263), so a mismatch fails decrypt loud rather than silently
12
+ // letting ciphertext cut-and-pasted from another field decrypt. An erased
13
+ // subject yields the sentinel, which matches no real value.
14
+ export async function decryptStoredPii(
15
+ value: string,
16
+ field: string,
17
+ fallbackRequestId: string,
18
+ ): Promise<string> {
12
19
  const kms = configuredPiiSubjectKms();
13
20
  if (!kms) return value;
14
- const out = await decryptPiiFieldValues({ value }, ["value"], kms, {
21
+ const out = await decryptPiiFieldValues({ [field]: value }, [field], kms, {
15
22
  requestId: requestContext.get()?.requestId ?? fallbackRequestId,
16
23
  });
17
- const decrypted = out["value"];
24
+ const decrypted = out[field];
18
25
  return typeof decrypted === "string" ? decrypted : value;
19
26
  }
@@ -1,8 +1,13 @@
1
1
  // feature.ts contract tests for subscription-mollie.
2
2
 
3
3
  import { describe, expect, test } from "bun:test";
4
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
5
+ import type { MollieClient } from "@mollie/api-client";
6
+ import { describeSubscriptionProviderContract } from "../../billing-foundation/__tests__/subscription-provider-contract";
4
7
  import { MOLLIE_PROVIDER_NAME, SUBSCRIPTION_MOLLIE_FEATURE } from "../constants";
5
8
  import { createSubscriptionMollieFeature } from "../feature";
9
+ import { createMollieCheckoutSession } from "../plugin-methods";
10
+ import { type MollieClientShape, verifyAndParseMollieWebhook } from "../verify-webhook";
6
11
 
7
12
  const VALID_OPTIONS = {
8
13
  apiKey: "test_dummy_apikey",
@@ -98,9 +103,91 @@ describe("subscription-mollie — plugin-registration", () => {
98
103
  createPortalSession?: unknown;
99
104
  cancelSubscription?: unknown;
100
105
  };
106
+ // Checks the mounted feature (the contract below builds its plugin
107
+ // by hand-wiring and would not catch a wiring gap here).
101
108
  expect(typeof plugin?.verifyAndParseWebhook).toBe("function");
102
109
  expect(typeof plugin?.createCheckoutSession).toBe("function");
103
110
  expect(plugin?.createPortalSession).toBeUndefined();
104
111
  expect(plugin?.cancelSubscription).toBeUndefined();
105
112
  });
106
113
  });
114
+
115
+ // =============================================================================
116
+ // SubscriptionProviderPlugin contract
117
+ // =============================================================================
118
+ //
119
+ // Built directly from the plugin-method constructors (not from
120
+ // createSubscriptionMollieFeature's mounted plugin) — same reasoning as
121
+ // subscription-stripe's contract fixture: keeps SDK-call mocking local to
122
+ // the fixture instead of routing through the full feature-mount.
123
+
124
+ const CONTRACT_TENANT_ID = "tenant-contract";
125
+ const CONTRACT_PRICE_ID = "plan_pro";
126
+
127
+ function buildMollieContractFixture() {
128
+ const subscription = {
129
+ id: "sub_contract_001",
130
+ customerId: "cst_contract_001",
131
+ status: "active",
132
+ nextPaymentDate: "2026-06-15",
133
+ startDate: "2026-05-15",
134
+ metadata: { tenantId: CONTRACT_TENANT_ID, priceId: CONTRACT_PRICE_ID },
135
+ // biome-ignore lint/suspicious/noExplicitAny: minimal Mollie-SDK-shape mock
136
+ } as any;
137
+ const payment = {
138
+ id: "tr_contract_001",
139
+ customerId: "cst_contract_001",
140
+ subscriptionId: "sub_contract_001",
141
+ sequenceType: "first",
142
+ status: "paid",
143
+ // biome-ignore lint/suspicious/noExplicitAny: minimal Mollie-SDK-shape mock
144
+ } as any;
145
+
146
+ const webhookClient: MollieClientShape = {
147
+ payments: { get: async () => payment },
148
+ customerSubscriptions: {
149
+ get: async () => subscription,
150
+ list: async () => [],
151
+ create: async () => subscription,
152
+ },
153
+ };
154
+
155
+ const checkoutClient = {
156
+ customers: { create: async () => ({ id: "cus_contract" }) },
157
+ payments: {
158
+ create: async () => ({
159
+ getCheckoutUrl: () => "https://www.mollie.com/checkout/contract-test",
160
+ }),
161
+ },
162
+ } as unknown as MollieClient;
163
+
164
+ return {
165
+ plugin: {
166
+ verifyAndParseWebhook: verifyAndParseMollieWebhook(webhookClient, {
167
+ priceToTier: VALID_OPTIONS.priceToTier,
168
+ priceToConfig: VALID_OPTIONS.priceToConfig,
169
+ }),
170
+ createCheckoutSession: createMollieCheckoutSession(
171
+ checkoutClient,
172
+ VALID_OPTIONS.priceToConfig,
173
+ VALID_OPTIONS.webhookUrl,
174
+ ),
175
+ // createPortalSession + cancelSubscription deliberately omitted — Mollie limitation.
176
+ },
177
+ ctx: {} as HandlerContext,
178
+ checkout: {
179
+ priceId: CONTRACT_PRICE_ID,
180
+ tenantId: CONTRACT_TENANT_ID,
181
+ successUrl: "https://example.com/success",
182
+ cancelUrl: "https://example.com/cancel",
183
+ },
184
+ webhook: {
185
+ rawBody: JSON.stringify({ id: "tr_contract_001" }),
186
+ headers: { "content-type": "application/json" },
187
+ expectedTenantId: CONTRACT_TENANT_ID,
188
+ expectedTier: "pro",
189
+ },
190
+ };
191
+ }
192
+
193
+ describeSubscriptionProviderContract("subscription-mollie", buildMollieContractFixture);