@cosmicdrift/kumiko-bundled-features 0.307.0 → 0.309.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 (253) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +107 -0
  3. package/src/__tests__/access-invalidation-stream.integration.test.ts +1 -1
  4. package/src/__tests__/credential-scoped-sse-revoke.integration.test.ts +296 -0
  5. package/src/agent-tools/__tests__/agent-doc-lint.test.ts +1 -1
  6. package/src/agent-tools/__tests__/agent-manifest.test.ts +1 -1
  7. package/src/agent-tools/__tests__/agent-optout.test.ts +1 -1
  8. package/src/agent-tools/__tests__/agent-tools.integration.test.ts +1 -1
  9. package/src/agent-tools/__tests__/feature.test.ts +1 -1
  10. package/src/agent-tools/__tests__/tool-catalog.test.ts +1 -1
  11. package/src/agent-tools/agent-manifest.ts +1 -1
  12. package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +1 -1
  13. package/src/audit/escape-hatch-audit-sink.ts +1 -1
  14. package/src/audit/handlers/details.query.ts +1 -1
  15. package/src/audit/handlers/list.query.ts +1 -1
  16. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +1 -1
  17. package/src/auth-email-password/__tests__/seed-admin-guarded.integration.test.ts +31 -0
  18. package/src/auth-email-password/changes.json +6 -0
  19. package/src/auth-email-password/feature.ts +1 -1
  20. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  21. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +1 -1
  22. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -1
  23. package/src/auth-email-password/handlers/invite-accept.write.ts +1 -1
  24. package/src/auth-email-password/handlers/invite-create.write.ts +1 -1
  25. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -1
  26. package/src/auth-email-password/handlers/login.write.ts +1 -1
  27. package/src/auth-email-password/handlers/logout.write.ts +1 -1
  28. package/src/auth-email-password/handlers/reset-password.write.ts +1 -1
  29. package/src/auth-email-password/handlers/self-registration-status.query.ts +1 -1
  30. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  31. package/src/auth-email-password/handlers/signup-request.write.ts +1 -1
  32. package/src/auth-email-password/handlers/token-request-handler.ts +1 -1
  33. package/src/auth-email-password/handlers/verify-email.write.ts +1 -1
  34. package/src/auth-email-password/password-policy.ts +1 -1
  35. package/src/auth-email-password/seeding.ts +16 -3
  36. package/src/auth-mfa/__tests__/session-scoped-sse-revoke.integration.test.ts +1 -1
  37. package/src/auth-mfa/handlers/disable.write.ts +1 -1
  38. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -1
  39. package/src/auth-mfa/handlers/enable-confirm.write.ts +1 -1
  40. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +1 -1
  41. package/src/auth-mfa/handlers/enable-start.write.ts +1 -1
  42. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +1 -1
  43. package/src/auth-mfa/handlers/status.query.ts +1 -1
  44. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  45. package/src/billing-foundation/billing-info-query.ts +1 -1
  46. package/src/billing-foundation/events.ts +1 -1
  47. package/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -1
  48. package/src/billing-foundation/handlers/create-portal-session.write.ts +1 -1
  49. package/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -1
  50. package/src/billing-foundation/handlers/process-event.write.ts +1 -1
  51. package/src/billing-foundation/handlers/process-payment-event.write.ts +1 -1
  52. package/src/cap-counter/__tests__/cap-counter.integration.test.ts +1 -1
  53. package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +1 -1
  54. package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +1 -1
  55. package/src/cap-counter/book-cap-usage.ts +1 -1
  56. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  57. package/src/cap-counter/handlers/increment-rolling.write.ts +1 -1
  58. package/src/cap-counter/handlers/increment.write.ts +1 -1
  59. package/src/cap-counter/handlers/mark-soft-warned.write.ts +1 -1
  60. package/src/cap-overview/handlers/caps-usage.query.ts +1 -1
  61. package/src/cap-overview/handlers/tenant-caps-list.query.ts +1 -1
  62. package/src/cap-overview/handlers/tenant-options.query.ts +1 -1
  63. package/src/channel-in-app/handlers/inbox.query.ts +1 -1
  64. package/src/channel-in-app/handlers/mark-all-read.write.ts +1 -1
  65. package/src/channel-in-app/handlers/mark-read.write.ts +1 -1
  66. package/src/channel-in-app/handlers/unread-count.query.ts +1 -1
  67. package/src/compliance-profiles/handlers/for-tenant.query.ts +1 -1
  68. package/src/compliance-profiles/handlers/list-profiles.query.ts +1 -1
  69. package/src/compliance-profiles/handlers/needs-profile.query.ts +1 -1
  70. package/src/compliance-profiles/handlers/set-profile.write.ts +1 -1
  71. package/src/compliance-profiles/handlers/sub-processors.query.ts +1 -1
  72. package/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +1 -1
  73. package/src/config/__tests__/backing-secrets.integration.test.ts +1 -1
  74. package/src/config/__tests__/config.integration.test.ts +1 -1
  75. package/src/config/__tests__/tz-resolution.integration.test.ts +1 -1
  76. package/src/config/handlers/cascade.query.ts +1 -1
  77. package/src/config/handlers/readiness.query.ts +1 -1
  78. package/src/config/handlers/reset.write.ts +1 -1
  79. package/src/config/handlers/schema.query.ts +1 -1
  80. package/src/config/handlers/set.write.ts +1 -1
  81. package/src/config/handlers/values.query.ts +1 -1
  82. package/src/crypto-shredding/handlers/forget-subject.write.ts +1 -1
  83. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
  84. package/src/custom-fields/__tests__/cross-tenant-field-delete.integration.test.ts +1 -1
  85. package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +1 -1
  86. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +1 -1
  87. package/src/custom-fields/__tests__/field-access.integration.test.ts +1 -1
  88. package/src/custom-fields/__tests__/quota.integration.test.ts +1 -1
  89. package/src/custom-fields/__tests__/retention.integration.test.ts +1 -1
  90. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +1 -1
  91. package/src/custom-fields/events.ts +1 -1
  92. package/src/custom-fields/feature.ts +1 -1
  93. package/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
  94. package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  95. package/src/custom-fields/lib/value-schema.ts +1 -1
  96. package/src/custom-fields/schemas.ts +1 -1
  97. package/src/data-retention/__tests__/retention-cleanup-files.integration.test.ts +454 -0
  98. package/src/data-retention/changes.json +6 -0
  99. package/src/data-retention/feature.ts +1 -0
  100. package/src/data-retention/handlers/policy-for.query.ts +1 -1
  101. package/src/data-retention/override-schema.ts +1 -1
  102. package/src/data-retention/run-retention-cleanup.ts +312 -5
  103. package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +1 -1
  104. package/src/delivery/__tests__/delivery.integration.test.ts +1 -1
  105. package/src/delivery/events.ts +1 -1
  106. package/src/delivery/feature.ts +1 -1
  107. package/src/delivery/handlers/log.query.ts +1 -1
  108. package/src/delivery/handlers/preferences.query.ts +1 -1
  109. package/src/delivery/handlers/set-preference.write.ts +1 -1
  110. package/src/delivery/jobs.ts +1 -1
  111. package/src/delivery/unsubscribe.ts +1 -1
  112. package/src/derivatives-sharp/__tests__/derivatives-sharp.integration.test.ts +1 -1
  113. package/src/document-ingest-foundation/events.ts +1 -1
  114. package/src/document-ingest-foundation/feature.ts +1 -1
  115. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +1 -1
  116. package/src/feature-toggles/events.ts +1 -1
  117. package/src/feature-toggles/handlers/list.query.ts +1 -1
  118. package/src/feature-toggles/handlers/registered.query.ts +1 -1
  119. package/src/feature-toggles/handlers/set.write.ts +1 -1
  120. package/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  121. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +1 -1
  122. package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +1 -1
  123. package/src/folders/handlers/delete-folder.write.ts +1 -1
  124. package/src/folders/schemas.ts +1 -1
  125. package/src/form-draft/schemas.ts +1 -1
  126. package/src/inbound-mail-foundation/events.ts +1 -1
  127. package/src/inbound-mail-foundation/handlers/connect-account.write.ts +1 -1
  128. package/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +1 -1
  129. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
  130. package/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -1
  131. package/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -1
  132. package/src/inbound-mail-foundation/handlers/update-account.write.ts +1 -1
  133. package/src/inbound-provider-imap/credential-document.ts +1 -1
  134. package/src/jobs/__tests__/jobs-catalog.integration.test.ts +1 -1
  135. package/src/jobs/__tests__/tenant-job-failures.integration.test.ts +1 -1
  136. package/src/jobs/events.ts +1 -1
  137. package/src/jobs/handlers/catalog.query.ts +1 -1
  138. package/src/jobs/handlers/detail.query.ts +1 -1
  139. package/src/jobs/handlers/list.query.ts +1 -1
  140. package/src/jobs/handlers/projection-rebuild.job.ts +1 -1
  141. package/src/jobs/handlers/reindex-entity.job.ts +1 -1
  142. package/src/jobs/handlers/retry.write.ts +1 -1
  143. package/src/jobs/handlers/tenant-failures.query.ts +1 -1
  144. package/src/jobs/handlers/trigger.write.ts +1 -1
  145. package/src/ledger/handlers/reports.query.ts +1 -1
  146. package/src/ledger/schemas.ts +1 -1
  147. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +1 -1
  148. package/src/managed-pages/handlers/branding.query.ts +1 -1
  149. package/src/managed-pages/handlers/by-slug.query.ts +1 -1
  150. package/src/managed-pages/handlers/by-tenant-published.query.ts +1 -1
  151. package/src/managed-pages/handlers/set.write.ts +1 -1
  152. package/src/notes-history/changes.json +7 -0
  153. package/src/notes-history/schemas.ts +1 -1
  154. package/src/notes-history/web/notes-section.tsx +37 -36
  155. package/src/personal-access-tokens/feature.ts +10 -0
  156. package/src/personal-access-tokens/handlers/available-scopes.query.ts +1 -1
  157. package/src/personal-access-tokens/handlers/create.write.ts +1 -1
  158. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  159. package/src/personal-access-tokens/handlers/revoke.write.ts +18 -2
  160. package/src/personal-access-tokens/index.ts +7 -0
  161. package/src/personal-access-tokens/pat-revoked-event.ts +23 -0
  162. package/src/personal-access-tokens/revoke-for-user.ts +31 -0
  163. package/src/rate-limiting/__tests__/rate-limiting.integration.test.ts +1 -1
  164. package/src/rate-limiting/handlers/status.query.ts +1 -1
  165. package/src/readiness/handlers/status.query.ts +1 -1
  166. package/src/secrets/feature.ts +1 -1
  167. package/src/secrets/handlers/delete.write.ts +1 -1
  168. package/src/secrets/handlers/list.query.ts +1 -1
  169. package/src/secrets/handlers/set.write.ts +1 -1
  170. package/src/secrets/secrets-context.ts +1 -1
  171. package/src/seo/handlers/seo-config.query.ts +1 -1
  172. package/src/sessions/handlers/detail.query.ts +1 -1
  173. package/src/sessions/handlers/list.query.ts +1 -1
  174. package/src/sessions/handlers/mine.query.ts +1 -1
  175. package/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
  176. package/src/sessions/handlers/revoke-all-others.write.ts +1 -1
  177. package/src/sessions/handlers/revoke.write.ts +1 -1
  178. package/src/sessions/session-callbacks.ts +25 -1
  179. package/src/sessions/session-revoked-event.ts +6 -1
  180. package/src/shared/__tests__/row-bound-grant.integration.test.ts +1 -1
  181. package/src/step-dispatcher/mail-runner.ts +1 -1
  182. package/src/step-dispatcher/webhook-runner.ts +1 -1
  183. package/src/subscription-mollie/feature.ts +1 -1
  184. package/src/subscription-stripe/feature.ts +1 -1
  185. package/src/tags/schemas.ts +1 -1
  186. package/src/template-resolver/handlers/by-slug.query.ts +1 -1
  187. package/src/template-resolver/handlers/by-tenant.query.ts +1 -1
  188. package/src/template-resolver/handlers/collection-item.query.ts +1 -1
  189. package/src/template-resolver/handlers/collection-list.query.ts +1 -1
  190. package/src/template-resolver/handlers/collection-set.write.ts +1 -1
  191. package/src/template-resolver/handlers/find-by-id.query.ts +1 -1
  192. package/src/template-resolver/handlers/list.query.ts +1 -1
  193. package/src/template-resolver/handlers/set.write.ts +1 -1
  194. package/src/template-resolver/handlers/shared.ts +1 -1
  195. package/src/template-resolver/handlers/toggle-status.write.ts +1 -1
  196. package/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  197. package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +1 -1
  198. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  199. package/src/tenant/handlers/add-member.write.ts +1 -1
  200. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  201. package/src/tenant/handlers/create.write.ts +1 -1
  202. package/src/tenant/handlers/invitations.query.ts +1 -1
  203. package/src/tenant/handlers/list.query.ts +1 -1
  204. package/src/tenant/handlers/me.query.ts +1 -1
  205. package/src/tenant/handlers/member-directory.query.ts +1 -1
  206. package/src/tenant/handlers/members.query.ts +1 -1
  207. package/src/tenant/handlers/memberships.query.ts +1 -1
  208. package/src/tenant/handlers/remove-member.write.ts +1 -1
  209. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  210. package/src/tenant/handlers/team-list.query.ts +1 -1
  211. package/src/tenant/handlers/tenant-directory.query.ts +1 -1
  212. package/src/tenant/handlers/toggle-enabled.write.ts +1 -1
  213. package/src/tenant/handlers/update-member-roles.write.ts +1 -1
  214. package/src/tenant/handlers/update.write.ts +1 -1
  215. package/src/tenant-handover/events.ts +1 -1
  216. package/src/tenant-handover/handlers/claim.write.ts +1 -1
  217. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +8 -24
  218. package/src/tenant-lifecycle/events.ts +1 -1
  219. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -1
  220. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  221. package/src/tenant-settings/tenant-defaults.ts +1 -1
  222. package/src/tier-engine/__tests__/tier-resolver.integration.test.ts +1 -1
  223. package/src/tier-engine/feature.ts +1 -1
  224. package/src/tier-engine/handlers/active-tier.query.ts +1 -1
  225. package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
  226. package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
  227. package/src/user/__tests__/user-global-tenancy.integration.test.ts +1 -1
  228. package/src/user/handlers/create.write.ts +1 -1
  229. package/src/user/handlers/find-for-auth.query.ts +1 -1
  230. package/src/user/handlers/me.query.ts +1 -1
  231. package/src/user/handlers/update.write.ts +1 -1
  232. package/src/user/roles-input-schema.ts +1 -1
  233. package/src/user/seeding.ts +29 -20
  234. package/src/user-data-rights/__tests__/boot-checks.test.ts +1 -1
  235. package/src/user-data-rights/changes.json +6 -0
  236. package/src/user-data-rights/feature.ts +1 -0
  237. package/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
  238. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -1
  239. package/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
  240. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  241. package/src/user-data-rights/handlers/export-status.query.ts +1 -1
  242. package/src/user-data-rights/handlers/lift-restriction.write.ts +1 -1
  243. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -1
  244. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
  245. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
  246. package/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  247. package/src/user-data-rights/handlers/request-export.write.ts +1 -1
  248. package/src/user-data-rights/handlers/restrict-account.write.ts +1 -1
  249. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
  250. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +8 -61
  251. package/src/user-profile/handlers/change-email.write.ts +1 -1
  252. package/src/workflow-runner/__tests__/pending-projection.integration.test.ts +1 -1
  253. package/src/workflow-runner/handlers/resume-run.write.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.307.0",
3
+ "version": "0.309.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.307.0",
137
- "@cosmicdrift/kumiko-framework": "0.307.0",
138
- "@cosmicdrift/kumiko-headless": "0.307.0",
139
- "@cosmicdrift/kumiko-renderer": "0.307.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.307.0",
141
- "@cosmicdrift/kumiko-types": "0.307.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.309.0",
137
+ "@cosmicdrift/kumiko-framework": "0.309.0",
138
+ "@cosmicdrift/kumiko-headless": "0.309.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.309.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.309.0",
141
+ "@cosmicdrift/kumiko-types": "0.309.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.307.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.307.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.309.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.309.0",
172
172
  "jsqr": "^1.4.0"
173
173
  }
174
174
  }
@@ -44,6 +44,11 @@ import { createAuthEmailPasswordFeature } from "../auth-email-password/feature";
44
44
  import { createConfigFeature } from "../config";
45
45
  import { createConfigResolver } from "../config/resolver";
46
46
  import { configValuesTable } from "../config/table";
47
+ import { PatHandlers } from "../personal-access-tokens/constants";
48
+ import { createPersonalAccessTokensFeature } from "../personal-access-tokens/feature";
49
+ import { revokeAllPatTokensForUser } from "../personal-access-tokens/revoke-for-user";
50
+ import { apiTokenEntity, apiTokenTable } from "../personal-access-tokens/schema/api-token";
51
+ import type { PatScopeConfig } from "../personal-access-tokens/scopes";
47
52
  import { makeSessionHelpers } from "../sessions/__tests__/test-helpers";
48
53
  import { SessionHandlers } from "../sessions/constants";
49
54
  import { createSessionsFeature } from "../sessions/feature";
@@ -66,6 +71,10 @@ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
66
71
  const encryptionKey = randomBytes(32).toString("base64");
67
72
  const TENANT = testTenantId(1);
68
73
 
74
+ const PAT_SCOPES: PatScopeConfig = {
75
+ probe: { label: "Probe", read: ["personal-access-tokens:query:mine"] },
76
+ };
77
+
69
78
  // Collects every userId the consumer pushed an invalidation for during the
70
79
  // running test, in push order. Reset in beforeEach.
71
80
  let invalidated: string[];
@@ -90,6 +99,10 @@ beforeAll(async () => {
90
99
  createAuthEmailPasswordFeature(),
91
100
  authFoundationFeature,
92
101
  createSessionsFeature({ autoRevokeOnPasswordChange: bound.asMassRevoker() }),
102
+ createPersonalAccessTokensFeature({
103
+ scopes: PAT_SCOPES,
104
+ autoRevokeOnPasswordChange: (userId) => revokeAllPatTokensForUser(stack.db, userId),
105
+ }),
93
106
  ],
94
107
  extraContext: { configResolver: resolver, configEncryption: encryption },
95
108
  authConfig: {
@@ -107,6 +120,7 @@ beforeAll(async () => {
107
120
  await unsafeCreateEntityTable(stack.db, userEntity);
108
121
  await unsafeCreateEntityTable(stack.db, tenantEntity);
109
122
  await unsafeCreateEntityTable(stack.db, userSessionEntity);
123
+ await unsafeCreateEntityTable(stack.db, apiTokenEntity);
110
124
  await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
111
125
  });
112
126
 
@@ -119,6 +133,7 @@ beforeEach(async () => {
119
133
  userTable,
120
134
  tenantMembershipsTable,
121
135
  userSessionTable,
136
+ apiTokenTable,
122
137
  eventsTable,
123
138
  ]);
124
139
  invalidated = [];
@@ -309,4 +324,96 @@ describe("access-invalidation event consumer", () => {
309
324
  expect(sessionRow).toBeTruthy();
310
325
  expect(sessionRow?.revokedAt).not.toBeNull();
311
326
  });
327
+
328
+ test("session-revoked via logout (sessionRevoker, streamScope: revoked-sessions) still pushes an invalidation for that user", async () => {
329
+ const { userId } = await h.seedUser("logout-invalidates@example.com", "pw-long-enough");
330
+ const { token } = await h.login("logout-invalidates@example.com", "pw-long-enough");
331
+ trackInvalidation(userId);
332
+
333
+ // The raw sessionRevoker callback behind /api/auth/logout used to append
334
+ // no event at all — it now sets streamScope: "revoked-sessions"
335
+ // instead of the old userwide default, but a broad
336
+ // subscribeAccessInvalidation listener (no credential passed here, same
337
+ // as every other test in this file) still fires: this proves the new
338
+ // narrow scope still reaches the consumer, not just the pre-existing
339
+ // userwide paths above.
340
+ const logoutRes = await h.authedPost("/api/auth/logout", token);
341
+ expect(logoutRes.status).toBe(200);
342
+
343
+ const events = await selectMany(stack.db, eventsTable, {
344
+ type: "sessions:event:session-revoked",
345
+ });
346
+ expect(events.length).toBeGreaterThan(0);
347
+
348
+ await stack.eventDispatcher?.runOnce();
349
+
350
+ expect(invalidated).toEqual([userId]);
351
+ });
352
+
353
+ test("pat-revoked (self-service PAT revoke) pushes an invalidation for that user", async () => {
354
+ const { userId } = await h.seedUser("pat-revoke-invalidates@example.com", "pw-long-enough");
355
+ const { token } = await h.login("pat-revoke-invalidates@example.com", "pw-long-enough");
356
+ trackInvalidation(userId);
357
+
358
+ const mint = await h.authedPost("/api/write", token, {
359
+ type: PatHandlers.create,
360
+ payload: {
361
+ name: "probe",
362
+ scopes: ["probe:read"],
363
+ currentPassword: "pw-long-enough",
364
+ },
365
+ });
366
+ expect(mint.status).toBe(200);
367
+ const { data } = (await mint.json()) as { data: { id: string } };
368
+
369
+ const revokeRes = await h.authedPost("/api/write", token, {
370
+ type: PatHandlers.revoke,
371
+ payload: { id: data.id },
372
+ });
373
+ expect(revokeRes.status).toBe(200);
374
+
375
+ const events = await selectMany(stack.db, eventsTable, {
376
+ type: "personal-access-tokens:event:pat-revoked",
377
+ });
378
+ expect(events.length).toBeGreaterThan(0);
379
+
380
+ await stack.eventDispatcher?.runOnce();
381
+
382
+ expect(invalidated).toEqual([userId]);
383
+ });
384
+
385
+ test("revokeAllPatTokensForUser (password-change auto-revoke) pushes an invalidation for that user", async () => {
386
+ const { userId } = await h.seedUser("pat-mass-invalidates@example.com", "old-pw-long-enough");
387
+ const { token } = await h.login("pat-mass-invalidates@example.com", "old-pw-long-enough");
388
+ trackInvalidation(userId);
389
+
390
+ const mint = await h.authedPost("/api/write", token, {
391
+ type: PatHandlers.create,
392
+ payload: {
393
+ name: "probe",
394
+ scopes: ["probe:read"],
395
+ currentPassword: "old-pw-long-enough",
396
+ },
397
+ });
398
+ expect(mint.status).toBe(200);
399
+
400
+ const res = await h.authedPost("/api/write", token, {
401
+ type: AuthHandlers.changePassword,
402
+ payload: { oldPassword: "old-pw-long-enough", newPassword: "new-pw-long-enough" },
403
+ });
404
+ expect(res.status).toBe(200);
405
+
406
+ const events = await selectMany(stack.db, eventsTable, {
407
+ type: "personal-access-tokens:event:pat-revoked",
408
+ });
409
+ expect(events.length).toBeGreaterThan(0);
410
+
411
+ await stack.eventDispatcher?.runOnce();
412
+
413
+ // A password change fires both sessionMassRevoker (userwide,
414
+ // session-revoked) and this suite's PAT autoRevokeOnPasswordChange
415
+ // (pat-tokens-scoped, pat-revoked) — the broad listener here (no
416
+ // credential passed) fires once per publish, so twice for the same user.
417
+ expect(invalidated).toEqual([userId, userId]);
418
+ });
312
419
  });
@@ -26,7 +26,7 @@ import {
26
26
  createTestEnvelopeCipher,
27
27
  resetTestTables,
28
28
  } from "@cosmicdrift/kumiko-framework/testing";
29
- import { z } from "zod";
29
+ import * as z from "zod";
30
30
  import { AuthHandlers } from "../auth-email-password/constants";
31
31
  import { createAuthEmailPasswordFeature } from "../auth-email-password/feature";
32
32
  import { createConfigFeature } from "../config";
@@ -0,0 +1,296 @@
1
+ // Credential-scoped SSE revoke.
2
+ //
3
+ // Logout/tenant-switch (sessionRevoker, session-callbacks.ts) and PAT revoke
4
+ // (revoke.write.ts / revokeAllPatTokensForUser) used to make no event at
5
+ // all, so the access-invalidation consumer never heard about them and an
6
+ // already-open SSE stream on that exact credential survived until its
7
+ // JWT/token expired on its own. Both now append a scoped session-revoked /
8
+ // pat-revoked event and the consumer closes exactly the matching stream(s),
9
+ // leaving every other credential's stream of the same user open.
10
+ //
11
+ // Real stack throughout: setupTestStack + real HTTP (createLiveDispatcher for
12
+ // the SSE streams, stack.http/h.authedPost for the writes), never
13
+ // createTestDispatcher.
14
+
15
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
16
+ import { randomBytes } from "node:crypto";
17
+ import {
18
+ authFoundationFeature,
19
+ resolveTokenVerifier,
20
+ } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
21
+ import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
22
+ import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
23
+ import { createInMemoryLoginRateLimiter, generateToken } from "@cosmicdrift/kumiko-framework/api";
24
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
26
+ import {
27
+ setupTestStack,
28
+ type TestStack,
29
+ testTenantId,
30
+ unsafeCreateEntityTable,
31
+ unsafePushTables,
32
+ } from "@cosmicdrift/kumiko-framework/stack";
33
+ import {
34
+ createLateBoundHolder,
35
+ createTestEnvelopeCipher,
36
+ resetTestTables,
37
+ } from "@cosmicdrift/kumiko-framework/testing";
38
+ import * as z from "zod";
39
+ import { AuthHandlers } from "../auth-email-password/constants";
40
+ import { createAuthEmailPasswordFeature } from "../auth-email-password/feature";
41
+ import { createConfigFeature } from "../config";
42
+ import { createConfigResolver } from "../config/resolver";
43
+ import { configValuesTable } from "../config/table";
44
+ import { PatHandlers } from "../personal-access-tokens/constants";
45
+ import { createPersonalAccessTokensFeature } from "../personal-access-tokens/feature";
46
+ import { revokeAllPatTokensForUser } from "../personal-access-tokens/revoke-for-user";
47
+ import { apiTokenEntity, apiTokenTable } from "../personal-access-tokens/schema/api-token";
48
+ import type { PatScopeConfig } from "../personal-access-tokens/scopes";
49
+ import { makeSessionHelpers } from "../sessions/__tests__/test-helpers";
50
+ import { createSessionsFeature } from "../sessions/feature";
51
+ import { userSessionEntity, userSessionTable } from "../sessions/schema/user-session";
52
+ import { createSessionCallbacks, type SessionCallbacks } from "../sessions/session-callbacks";
53
+ import { sessionCallbacksFromLateBound } from "../sessions/testing";
54
+ import { createTenantFeature } from "../tenant";
55
+ import { tenantMembershipsTable } from "../tenant/membership-table";
56
+ import { tenantEntity } from "../tenant/schema/tenant";
57
+ import { createUserFeature } from "../user/feature";
58
+ import { userEntity, userTable } from "../user/schema/user";
59
+
60
+ let stack: TestStack;
61
+ let h: ReturnType<typeof makeSessionHelpers>;
62
+ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
63
+
64
+ const encryptionKey = randomBytes(32).toString("base64");
65
+ const TENANT = testTenantId(1);
66
+
67
+ const SCOPES: PatScopeConfig = {
68
+ probe: { label: "Probe stream", read: ["stream-probe:stream:probe:tail"] },
69
+ };
70
+
71
+ const chunkGates = new Map<string, { promise: Promise<void>; release: () => void }>();
72
+
73
+ function createGate(gateId: string): () => void {
74
+ let release!: () => void;
75
+ const promise = new Promise<void>((resolve) => {
76
+ release = resolve;
77
+ });
78
+ chunkGates.set(gateId, { promise, release });
79
+ return release;
80
+ }
81
+
82
+ const streamProbeFeature = defineFeature("stream-probe", (r) => {
83
+ r.streamHandler(
84
+ "probe:tail",
85
+ z.object({ gateId: z.string().min(1) }),
86
+ async function* (event) {
87
+ yield { phase: "open" as const };
88
+ const gate = chunkGates.get(event.payload.gateId);
89
+ if (!gate) throw new Error(`missing gate ${event.payload.gateId}`);
90
+ await gate.promise;
91
+ yield { phase: "survived" as const };
92
+ },
93
+ { access: { roles: ["User"] } },
94
+ );
95
+ });
96
+
97
+ function buildLiveFetch(bearerToken: string): {
98
+ readonly fetch: typeof fetch;
99
+ readonly csrfToken: string;
100
+ } {
101
+ const csrfToken = generateToken();
102
+ const fetchImpl = (async (url: unknown, init: RequestInit | undefined) => {
103
+ const reqInit: RequestInit = {
104
+ ...(init ?? {}),
105
+ headers: {
106
+ ...(init?.headers ?? {}),
107
+ Authorization: `Bearer ${bearerToken}`,
108
+ },
109
+ };
110
+ return stack.app.request(String(url), reqInit);
111
+ }) as unknown as typeof fetch;
112
+ return { fetch: fetchImpl, csrfToken };
113
+ }
114
+
115
+ async function openProbeStream(bearerToken: string): Promise<{
116
+ iter: AsyncIterator<{ phase: string }>;
117
+ release: () => void;
118
+ }> {
119
+ const gateId = generateToken();
120
+ const release = createGate(gateId);
121
+ const { fetch, csrfToken } = buildLiveFetch(bearerToken);
122
+ const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
123
+ const iter = dispatcher
124
+ .stream<{ phase: string }>("stream-probe:stream:probe:tail", { gateId })
125
+ [Symbol.asyncIterator]();
126
+ const first = await iter.next();
127
+ expect(first.done).toBe(false);
128
+ expect(first.value).toEqual({ phase: "open" });
129
+ return { iter, release };
130
+ }
131
+
132
+ // The append's NOTIFY can wake a dispatcher pass before the test's own
133
+ // runOnce(), and runOnce() skips a consumer that pass is still busy with.
134
+ // Draining first makes the invalidation land before the stream assertions.
135
+ async function deliverPendingEvents(): Promise<void> {
136
+ await stack.eventDispatcher?.drain();
137
+ await stack.eventDispatcher?.runOnce();
138
+ }
139
+
140
+ async function mintPat(cookieToken: string): Promise<{ id: string; token: string }> {
141
+ const res = await h.authedPost("/api/write", cookieToken, {
142
+ type: PatHandlers.create,
143
+ payload: {
144
+ name: "probe-token",
145
+ scopes: ["probe:read"],
146
+ currentPassword: "pw-long-enough",
147
+ },
148
+ });
149
+ expect(res.status).toBe(200);
150
+ const body = (await res.json()) as { data: { id: string; token: string } };
151
+ return body.data;
152
+ }
153
+
154
+ beforeAll(async () => {
155
+ const encryption = createTestEnvelopeCipher(encryptionKey);
156
+ const resolver = createConfigResolver({ cipher: encryption });
157
+ const bound = sessionCallbacksFromLateBound(callbacks);
158
+
159
+ stack = await setupTestStack({
160
+ features: [
161
+ createConfigFeature(),
162
+ createUserFeature(),
163
+ createTenantFeature(),
164
+ createAuthEmailPasswordFeature(),
165
+ authFoundationFeature,
166
+ createSessionsFeature({ autoRevokeOnPasswordChange: bound.asMassRevoker() }),
167
+ createPersonalAccessTokensFeature({
168
+ scopes: SCOPES,
169
+ autoRevokeOnPasswordChange: (userId) => revokeAllPatTokensForUser(stack.db, userId),
170
+ }),
171
+ streamProbeFeature,
172
+ ],
173
+ extraContext: { configResolver: resolver, configEncryption: encryption },
174
+ authConfig: {
175
+ ...bound.asAuthConfig(),
176
+ membershipQuery: "tenant:query:memberships",
177
+ loginHandler: AuthHandlers.login,
178
+ tokenVerifier: (raw: string) =>
179
+ resolveTokenVerifier({ db: stack.db, registry: stack.registry }, raw),
180
+ patRateLimiter: createInMemoryLoginRateLimiter(100, 60_000),
181
+ },
182
+ });
183
+ callbacks.set(createSessionCallbacks({ db: stack.db }));
184
+ h = makeSessionHelpers(stack, TENANT, bound.asAuthConfig().sessionCreator as SessionCreator);
185
+
186
+ await unsafeCreateEntityTable(stack.db, userEntity);
187
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
188
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
189
+ await unsafeCreateEntityTable(stack.db, apiTokenEntity);
190
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
191
+ });
192
+
193
+ afterAll(async () => {
194
+ await stack.cleanup();
195
+ });
196
+
197
+ beforeEach(async () => {
198
+ await resetTestTables(stack.db, [
199
+ userTable,
200
+ tenantMembershipsTable,
201
+ userSessionTable,
202
+ apiTokenTable,
203
+ eventsTable,
204
+ ]);
205
+ chunkGates.clear();
206
+ });
207
+
208
+ // Tests release a stream's gate BEFORE asserting it closed: the invalidation
209
+ // already fired during runOnce(), so a revoked stream still rejects, while a
210
+ // stream that was wrongly left open yields its next chunk and fails the
211
+ // assertion instead of hanging on a gate nobody releases.
212
+ describe("logout closes only that cookie session's SSE stream", () => {
213
+ test("session 1 logs out, session 1's stream closes, session 2's stream survives", async () => {
214
+ await h.seedUser("logout-scope@example.com", "pw-long-enough");
215
+ const session1 = await h.login("logout-scope@example.com", "pw-long-enough");
216
+ const session2 = await h.login("logout-scope@example.com", "pw-long-enough");
217
+
218
+ const stream1 = await openProbeStream(session1.token);
219
+ const stream2 = await openProbeStream(session2.token);
220
+
221
+ const logoutRes = await h.authedPost("/api/auth/logout", session1.token);
222
+ expect(logoutRes.status).toBe(200);
223
+
224
+ await deliverPendingEvents();
225
+
226
+ stream1.release();
227
+
228
+ await expect(stream1.iter.next()).rejects.toMatchObject({ code: "access_denied" });
229
+
230
+ stream2.release();
231
+ const survivedChunk = await stream2.iter.next();
232
+ expect(survivedChunk.done).toBe(false);
233
+ expect(survivedChunk.value).toEqual({ phase: "survived" });
234
+ });
235
+ });
236
+
237
+ describe("PAT revoke closes only that token's SSE stream", () => {
238
+ test("revoking PAT1 closes PAT1's stream, PAT2's stream and the cookie stream survive", async () => {
239
+ await h.seedUser("pat-scope@example.com", "pw-long-enough");
240
+ const session = await h.login("pat-scope@example.com", "pw-long-enough");
241
+ const pat1 = await mintPat(session.token);
242
+ const pat2 = await mintPat(session.token);
243
+
244
+ const cookieStream = await openProbeStream(session.token);
245
+ const pat1Stream = await openProbeStream(pat1.token);
246
+ const pat2Stream = await openProbeStream(pat2.token);
247
+
248
+ const revokeRes = await h.authedPost("/api/write", session.token, {
249
+ type: PatHandlers.revoke,
250
+ payload: { id: pat1.id },
251
+ });
252
+ expect(revokeRes.status).toBe(200);
253
+
254
+ await deliverPendingEvents();
255
+
256
+ pat1Stream.release();
257
+
258
+ await expect(pat1Stream.iter.next()).rejects.toMatchObject({ code: "access_denied" });
259
+
260
+ cookieStream.release();
261
+ pat2Stream.release();
262
+ const cookieSurvived = await cookieStream.iter.next();
263
+ expect(cookieSurvived.done).toBe(false);
264
+ expect(cookieSurvived.value).toEqual({ phase: "survived" });
265
+ const pat2Survived = await pat2Stream.iter.next();
266
+ expect(pat2Survived.done).toBe(false);
267
+ expect(pat2Survived.value).toEqual({ phase: "survived" });
268
+ });
269
+
270
+ test("revokeAllPatTokensForUser closes every PAT stream of the user, cookie stream survives", async () => {
271
+ const { userId } = await h.seedUser("pat-mass-scope@example.com", "pw-long-enough");
272
+ const session = await h.login("pat-mass-scope@example.com", "pw-long-enough");
273
+ const pat1 = await mintPat(session.token);
274
+ const pat2 = await mintPat(session.token);
275
+
276
+ const cookieStream = await openProbeStream(session.token);
277
+ const pat1Stream = await openProbeStream(pat1.token);
278
+ const pat2Stream = await openProbeStream(pat2.token);
279
+
280
+ const revokedCount = await revokeAllPatTokensForUser(stack.db, userId);
281
+ expect(revokedCount).toBe(2);
282
+
283
+ await deliverPendingEvents();
284
+
285
+ pat1Stream.release();
286
+
287
+ await expect(pat1Stream.iter.next()).rejects.toMatchObject({ code: "access_denied" });
288
+ pat2Stream.release();
289
+ await expect(pat2Stream.iter.next()).rejects.toMatchObject({ code: "access_denied" });
290
+
291
+ cookieStream.release();
292
+ const cookieSurvived = await cookieStream.iter.next();
293
+ expect(cookieSurvived.done).toBe(false);
294
+ expect(cookieSurvived.value).toEqual({ phase: "survived" });
295
+ });
296
+ });
@@ -4,7 +4,7 @@ import {
4
4
  defineWriteHandler,
5
5
  resolveAgentExposure,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import { z } from "zod";
7
+ import * as z from "zod";
8
8
  import { AgentDocGapKinds, findAgentDocGaps, formatAgentDocGap } from "../agent-doc-lint";
9
9
 
10
10
  const OPEN_ACCESS = {
@@ -7,7 +7,7 @@ import {
7
7
  defineFeature,
8
8
  } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import type { ReferenceFieldDef, TextFieldDef } from "@cosmicdrift/kumiko-framework/engine/types";
10
- import { z } from "zod";
10
+ import * as z from "zod";
11
11
  import { buildAgentManifest } from "../agent-manifest";
12
12
 
13
13
  const gatedEntity = createEntity({
@@ -7,7 +7,7 @@ import {
7
7
  defineEntityListHandler,
8
8
  defineFeature,
9
9
  } from "@cosmicdrift/kumiko-framework/engine";
10
- import { z } from "zod";
10
+ import * as z from "zod";
11
11
  import { authFoundationFeature } from "../../auth-foundation";
12
12
  import { AuthMfaHandlers, AuthMfaQueries, createAuthMfaFeature } from "../../auth-mfa";
13
13
  import { createConfigFeature } from "../../config";
@@ -16,7 +16,7 @@ import {
16
16
  type TestStack,
17
17
  unsafeCreateEntityTable,
18
18
  } from "@cosmicdrift/kumiko-framework/stack";
19
- import { z } from "zod";
19
+ import * as z from "zod";
20
20
  import { buildAgentManifest } from "../agent-manifest";
21
21
  import { buildToolCatalog, toolNameForQn } from "../tool-catalog";
22
22
  import type { ToolDispatcher } from "../tool-dispatch";
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, spyOn, test } from "bun:test";
2
2
  import { defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
- import { z } from "zod";
3
+ import * as z from "zod";
4
4
  import { AGENT_TOOLS_FEATURE_NAME, createAgentToolsFeature } from "../feature";
5
5
 
6
6
  async function noopWriteHandler() {
@@ -6,7 +6,7 @@ import {
6
6
  createTextField,
7
7
  defineFeature,
8
8
  } from "@cosmicdrift/kumiko-framework/engine";
9
- import { z } from "zod";
9
+ import * as z from "zod";
10
10
  import { buildAgentManifest } from "../agent-manifest";
11
11
  import { buildToolCatalog, toolNameForQn } from "../tool-catalog";
12
12
  import type { AgentManifest, AgentToolMode, RegistrySearchView } from "../types";
@@ -15,7 +15,7 @@ import {
15
15
  isAgentVisibleScreen,
16
16
  resolveAgentExposure,
17
17
  } from "@cosmicdrift/kumiko-framework/engine";
18
- import { z } from "zod";
18
+ import * as z from "zod";
19
19
  import type {
20
20
  AgentManifest,
21
21
  AgentManifestEntity,
@@ -12,7 +12,7 @@ import {
12
12
  type TestStack,
13
13
  testTenantId,
14
14
  } from "@cosmicdrift/kumiko-framework/stack";
15
- import { z } from "zod";
15
+ import * as z from "zod";
16
16
  import { createConfigFeature } from "../../config";
17
17
  import { createTenantFeature } from "../../tenant";
18
18
  import { createUserFeature } from "../../user";
@@ -2,7 +2,7 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { EscapeHatchAuditSink, EscapeHatchKind } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
5
- import { z } from "zod";
5
+ import * as z from "zod";
6
6
 
7
7
  export const ESCAPE_HATCH_USED_EVENT = "audit:event:escape-hatch-used";
8
8
  export const ESCAPE_HATCH_USE_AGGREGATE_TYPE = "escapeHatchUse";
@@ -5,7 +5,7 @@
5
5
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
6
6
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
8
- import { z } from "zod";
8
+ import * as z from "zod";
9
9
 
10
10
  export const detailsQuery = defineQueryHandler({
11
11
  name: "details",
@@ -15,7 +15,7 @@ import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-
15
15
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
16
16
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
17
  import { Temporal } from "temporal-polyfill";
18
- import { z } from "zod";
18
+ import * as z from "zod";
19
19
 
20
20
  const MAX_LIMIT = 100;
21
21
 
@@ -31,7 +31,7 @@ import {
31
31
  updateRows,
32
32
  waitFor,
33
33
  } from "@cosmicdrift/kumiko-framework/testing";
34
- import { z } from "zod";
34
+ import * as z from "zod";
35
35
  import {
36
36
  createComplianceProfilesFeature,
37
37
  tenantComplianceProfileEntity,
@@ -179,4 +179,35 @@ describe("seedAdminGuarded (offlot#114)", () => {
179
179
  expect(await userIds()).toHaveLength(2);
180
180
  expect((await userIds()).slice().sort()).toEqual([unrelated.id, admin.id].sort());
181
181
  });
182
+
183
+ test("a KMS without a blind-index key aborts instead of seeding a blind duplicate", async () => {
184
+ await seedAdminGuarded(stack.db, seedOptions);
185
+ resetBlindIndexKeyForTests();
186
+
187
+ await expect(seedAdminGuarded(stack.db, seedOptions)).rejects.toThrow(/blind-index key/);
188
+ expect(await userIds()).toHaveLength(1);
189
+ });
190
+
191
+ test("existing-user branch reconciles emailVerified onto the canonical row", async () => {
192
+ const first = await seedAdminGuarded(stack.db, seedOptions);
193
+ await blankBlindIndex();
194
+
195
+ const second = await seedAdminGuarded(stack.db, { ...seedOptions, emailVerified: true });
196
+
197
+ expect(second.id).toBe(first.id);
198
+ expect(await userIds()).toEqual([first.id]);
199
+ const rows = await selectMany<{ id: string; emailVerified: boolean }>(stack.db, userTable);
200
+ expect(rows.find((r) => r.id === first.id)?.emailVerified).toBe(true);
201
+ });
202
+
203
+ test("without a configured KMS, seeding runs the plaintext path unguarded", async () => {
204
+ resetPiiSubjectKmsForTests();
205
+ resetBlindIndexKeyForTests();
206
+
207
+ const first = await seedAdminGuarded(stack.db, seedOptions);
208
+ const second = await seedAdminGuarded(stack.db, seedOptions);
209
+
210
+ expect(second.id).toBe(first.id);
211
+ expect(await userIds()).toEqual([first.id]);
212
+ });
182
213
  });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.309.0",
4
+ "type": "fix",
5
+ "title": "Boot admin seed no longer duplicates the admin when the email blind-index lookup misses (fw#3101)",
6
+ "detail": "runProdApp and runDevApp used to call the unguarded `seedAdmin`, whose idempotency check is a plain email fetchOne that the query layer rewrites into `(email = $plaintext OR email_bidx = $hmac)` once a blind-index key is configured. Under KMS encryption `email` holds per-row ciphertext, so only the bidx arm can ever match — and it misses whenever a row's `email_bidx` is NULL (written before the key existed, or after a subject-key erase), silently inserting a second admin account for the same email. Both boot paths now call `seedAdminGuarded`, which under KMS decrypt-scans the active users for the email and reuses the oldest matching row instead of duplicating it. `seedAdminGuarded` also now aborts with a named error if a PII KMS is configured but no blind-index key is — that combination makes the lookup blind by construction — and reconciles `emailVerified: true` onto the existing row it resolves through the decrypt-scan branch, matching the existing-row reconcile `seedUser` already does on its own idempotency hit."
7
+ },
2
8
  {
3
9
  "version": "0.307.0",
4
10
  "type": "improvement",
@@ -1,5 +1,5 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { MIN_HMAC_SECRET_LENGTH } from "./constants";
4
4
  import type { AuthMailLocale } from "./email-templates";
5
5
  import { changePasswordWrite } from "./handlers/change-password.write";