@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 (152) hide show
  1. package/package.json +10 -6
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
  3. package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +50 -21
  5. package/src/audit/handlers/list.query.ts +1 -0
  6. package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
  8. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  9. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  10. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  12. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  13. package/src/auth-email-password/handlers/login.write.ts +8 -0
  14. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  15. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  16. package/src/auth-email-password/i18n.ts +2 -0
  17. package/src/auth-email-password/seeding.ts +41 -23
  18. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  19. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  20. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  21. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  22. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  23. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  24. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  25. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  26. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/auth-client.ts +32 -10
  28. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  29. package/src/auth-email-password/web/client-plugin.ts +15 -2
  30. package/src/auth-email-password/web/index.ts +5 -1
  31. package/src/auth-email-password/web/login-screen.tsx +7 -3
  32. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  33. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  34. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  35. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  36. package/src/auth-foundation/boot-checks.ts +74 -69
  37. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  38. package/src/auth-foundation/types.ts +3 -3
  39. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  40. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  41. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
  42. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  43. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  44. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  45. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  46. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  47. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  48. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  49. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  50. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  52. package/src/auth-mfa/web/i18n.ts +4 -0
  53. package/src/auth-mfa/web/mfa-client.ts +13 -2
  54. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  55. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  56. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  57. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  58. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  59. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  61. package/src/config/db/queries/resolver.ts +3 -3
  62. package/src/config/handlers/reencrypt.job.ts +36 -24
  63. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  64. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  65. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  66. package/src/document-ingest-foundation/entity.ts +10 -3
  67. package/src/document-ingest-foundation/feature.ts +26 -8
  68. package/src/document-ingest-foundation/index.ts +1 -0
  69. package/src/document-ingest-foundation/pages.ts +34 -0
  70. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  71. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  72. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  73. package/src/files-provider-s3/s3-provider.ts +12 -1
  74. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  76. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  77. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  78. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  79. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  80. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  81. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  82. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  83. package/src/jobs/feature.ts +6 -5
  84. package/src/jobs/job-instant.ts +9 -0
  85. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  86. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  87. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  88. package/src/page-render/__tests__/markdown.test.ts +29 -0
  89. package/src/page-render/markdown.ts +1 -9
  90. package/src/personal-access-tokens/feature.ts +1 -1
  91. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  92. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  93. package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
  94. package/src/sessions/feature.ts +12 -0
  95. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  96. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  97. package/src/sessions/handlers/revoke.write.ts +13 -0
  98. package/src/sessions/index.ts +7 -0
  99. package/src/sessions/session-callbacks.ts +58 -5
  100. package/src/sessions/session-revoked-event.ts +29 -0
  101. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  102. package/src/shared/chunked-entity-migration.ts +2 -2
  103. package/src/shared/grace-period.test.ts +26 -0
  104. package/src/shared/grace-period.ts +20 -0
  105. package/src/shared/index.ts +2 -0
  106. package/src/shared/map-with-concurrency.test.ts +104 -0
  107. package/src/shared/map-with-concurrency.ts +38 -0
  108. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  109. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  110. package/src/subscription-mollie/verify-webhook.ts +1 -0
  111. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  112. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  113. package/src/subscription-stripe/verify-webhook.ts +3 -2
  114. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  115. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  116. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  117. package/src/tenant/handlers/invitations.query.ts +13 -15
  118. package/src/tenant/handlers/members.query.ts +12 -12
  119. package/src/tenant/seeding.ts +110 -16
  120. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  121. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
  122. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
  123. package/src/tenant-settings/config.ts +44 -0
  124. package/src/tenant-settings/constants.ts +16 -0
  125. package/src/tenant-settings/feature.ts +49 -0
  126. package/src/tenant-settings/index.ts +8 -0
  127. package/src/tenant-settings/tenant-defaults.ts +67 -0
  128. package/src/user/handlers/find-for-auth.query.ts +1 -1
  129. package/src/user/seeding.ts +8 -0
  130. package/src/user-data-rights/COMPLIANCE.md +1 -1
  131. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  132. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  133. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  134. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
  135. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  136. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  137. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
  138. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  139. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  140. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  141. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  142. package/src/user-data-rights/feature.ts +45 -45
  143. package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
  144. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  145. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  146. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  147. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  148. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  149. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  150. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  151. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  152. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -11,6 +11,7 @@ import {
11
11
  resetPiiSubjectKmsForTests,
12
12
  } from "@cosmicdrift/kumiko-framework/crypto";
13
13
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
14
15
  import {
15
16
  setupTestStack,
16
17
  type TestStack,
@@ -41,6 +42,7 @@ import { SessionHandlers, SessionQueries } from "../constants";
41
42
  import { createSessionsFeature } from "../feature";
42
43
  import { userSessionEntity, userSessionTable } from "../schema/user-session";
43
44
  import { createSessionCallbacks, type SessionCallbacks } from "../session-callbacks";
45
+ import { SESSION_REVOKED_EVENT_QN, type SessionRevokedPayload } from "../session-revoked-event";
44
46
  import { sessionCallbacksFromLateBound } from "../testing";
45
47
  import { makeSessionHelpers } from "./test-helpers";
46
48
 
@@ -84,6 +86,7 @@ beforeAll(async () => {
84
86
  await unsafeCreateEntityTable(stack.db, tenantEntity);
85
87
  await unsafeCreateEntityTable(stack.db, userSessionEntity);
86
88
  await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
89
+ await createEventsTable(stack.db);
87
90
  });
88
91
 
89
92
  afterAll(async () => {
@@ -91,7 +94,12 @@ afterAll(async () => {
91
94
  });
92
95
 
93
96
  beforeEach(async () => {
94
- await resetTestTables(stack.db, [userTable, tenantMembershipsTable, userSessionTable]);
97
+ await resetTestTables(stack.db, [
98
+ userTable,
99
+ tenantMembershipsTable,
100
+ userSessionTable,
101
+ eventsTable,
102
+ ]);
95
103
  });
96
104
 
97
105
  describe("sessions feature — login → check → revoke → rejected", () => {
@@ -191,6 +199,69 @@ describe("sessions feature — login → check → revoke → rejected", () => {
191
199
  expect(preservedRevokedAt?.epochMilliseconds).toBe(originalRevokedAt?.epochMilliseconds);
192
200
  });
193
201
 
202
+ // #1559 — store_user_sessions stays a direct-write store (no CRUD
203
+ // lifecycle events, see feature.ts), but a successful revoke must still
204
+ // append a lightweight session-revoked event so a cross-instance
205
+ // consumer (#1560) can react. Asserted directly against kumiko_events —
206
+ // the response body carries no trace of it.
207
+ test("revoke appends a session-revoked event carrying the userId + sid", async () => {
208
+ const { userId } = await h.seedUser("event1@example.com", "pw-long-enough");
209
+ const { token, sid } = await h.login("event1@example.com", "pw-long-enough");
210
+
211
+ const res = await h.authedPost("/api/write", token, {
212
+ type: SessionHandlers.revoke,
213
+ payload: { id: sid },
214
+ });
215
+ expect(res.status).toBe(200);
216
+
217
+ const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
218
+ expect(events).toHaveLength(1);
219
+ const payload = events[0]?.["payload"] as SessionRevokedPayload;
220
+ expect(payload.userId).toBe(userId);
221
+ expect(payload.sessionIds).toEqual([sid]);
222
+ });
223
+
224
+ // Negative case: an already-revoked sid must NOT emit a second event —
225
+ // a spurious invalidation is the failure mode #1559 guards against.
226
+ test("revoking an already-revoked sid emits no additional event", async () => {
227
+ await h.seedUser("event2@example.com", "pw-long-enough");
228
+ const first = await h.login("event2@example.com", "pw-long-enough");
229
+
230
+ await h.authedPost("/api/write", first.token, {
231
+ type: SessionHandlers.revoke,
232
+ payload: { id: first.sid },
233
+ });
234
+
235
+ const second = await h.login("event2@example.com", "pw-long-enough");
236
+ const retry = await h.authedPost("/api/write", second.token, {
237
+ type: SessionHandlers.revoke,
238
+ payload: { id: first.sid },
239
+ });
240
+ expect(retry.status).toBe(422);
241
+
242
+ const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
243
+ expect(events).toHaveLength(1); // only the first, successful revoke
244
+ });
245
+
246
+ test("revoke-all-others appends one event listing every revoked sid", async () => {
247
+ const { userId } = await h.seedUser("event3@example.com", "pw-long-enough");
248
+ const a = await h.login("event3@example.com", "pw-long-enough");
249
+ const b = await h.login("event3@example.com", "pw-long-enough");
250
+ const c = await h.login("event3@example.com", "pw-long-enough");
251
+
252
+ const res = await h.authedPost("/api/write", b.token, {
253
+ type: SessionHandlers.revokeAllOthers,
254
+ payload: {},
255
+ });
256
+ expect(res.status).toBe(200);
257
+
258
+ const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
259
+ expect(events).toHaveLength(1);
260
+ const payload = events[0]?.["payload"] as SessionRevokedPayload;
261
+ expect(payload.userId).toBe(userId);
262
+ expect(new Set(payload.sessionIds)).toEqual(new Set([a.sid, c.sid]));
263
+ });
264
+
194
265
  test("session:mine only returns live sessions, marks the current one", async () => {
195
266
  await h.seedUser("mine@example.com", "pw-long-enough");
196
267
  // Three logins = three sessions (think: browser, mobile app, tablet)
@@ -24,6 +24,7 @@ import {
24
24
  type SessionAllOthersRevoker,
25
25
  type SessionMassRevoker,
26
26
  } from "./session-callbacks";
27
+ import { SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema } from "./session-revoked-event";
27
28
 
28
29
  export type SessionsFeatureOptions = {
29
30
  // A successful update on the `user` entity that changes the `passwordHash`
@@ -137,6 +138,17 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
137
138
  },
138
139
  } satisfies SessionStoreProvider);
139
140
 
141
+ // Custom domain-event for cross-instance access-invalidation (#1559).
142
+ // r.defineEvent registers the schema so ctx.unsafeAppendEvent (revoke,
143
+ // revoke-all-others) enforces it at append time. revoke-all-for-user
144
+ // appends via the low-level append() instead (needs to anchor on
145
+ // SYSTEM_TENANT_ID — see that handler for why) — append() does NOT
146
+ // consult the registry, so that callsite parses against
147
+ // sessionRevokedSchema explicitly, same guarantee via a different path.
148
+ // No projection: the payload IS the read, consumed directly off the
149
+ // event-store NOTIFY (#1560).
150
+ r.defineEvent(SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema);
151
+
140
152
  const handlers = {
141
153
  revoke: r.writeHandler(revokeWrite),
142
154
  revokeAllOthers: r.writeHandler(revokeAllOthersWrite),
@@ -1,21 +1,28 @@
1
1
  import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { access, defineWriteHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { append } from "@cosmicdrift/kumiko-framework/event-store";
4
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
3
5
  import { Temporal } from "temporal-polyfill";
4
6
  import { z } from "zod";
5
7
  import { userSessionTable } from "../schema/user-session";
8
+ import {
9
+ SESSION_REVOKED_AGGREGATE_TYPE,
10
+ SESSION_REVOKED_EVENT_QN,
11
+ sessionRevokedSchema,
12
+ } from "../session-revoked-event";
6
13
 
7
14
  // Mass-revoke ALL live sessions for a target user — privileged-only.
8
- // Used by user-data-rights:restrict-account zur Account-Freeze
9
- // (DSGVO Art. 18) sowie potenziell anderen ops-flows ("ban user",
10
- // "compromised account"). Im Gegensatz zu revoke-all-others wird
11
- // die ggf. aufrufende Session ebenfalls revoked — Caller ist System
12
- // (cron/operator/cross-feature), nicht der Endnutzer selbst.
15
+ // Used by user-data-rights:restrict-account for account-freeze (DSGVO
16
+ // Art. 18) and potentially other ops flows ("ban user", "compromised
17
+ // account"). Unlike revoke-all-others, the caller's own session (if any)
18
+ // is revoked too — the caller is System (cron/operator/cross-feature),
19
+ // not the end-user themselves.
13
20
  //
14
- // Tenant-scope: das userSession-Schema persistiert tenantId pro Row
15
- // (User kann mehrere Sessions in mehreren Tenants haben). Wir
16
- // revoken cross-tenant, weil "Account-Restriction" eine globale
17
- // User-Aussage ist (Forget-Pfad ist auch global, sieht User-Entity-
18
- // special-Doc). UPDATE filtert nur auf userId.
21
+ // Tenant-scope: the userSession schema persists tenantId per row (a user
22
+ // can have sessions in multiple tenants). We revoke cross-tenant because
23
+ // "account restriction" is a global user-level statement (the forget path
24
+ // is global too, see the User-Entity special doc). The UPDATE filters
25
+ // only on userId.
19
26
  export const revokeAllForUserWrite = defineWriteHandler({
20
27
  name: "user-session:revoke-all-for-user",
21
28
  schema: z.object({
@@ -23,13 +30,45 @@ export const revokeAllForUserWrite = defineWriteHandler({
23
30
  }),
24
31
  access: { roles: access.privileged },
25
32
  handler: async (event, ctx) => {
26
- const updated = await updateMany(
33
+ const updated = await updateMany<{ id: string }>(
27
34
  ctx.db.raw,
28
35
  userSessionTable,
29
36
  { revokedAt: Temporal.Now.instant() },
30
37
  { userId: event.payload.userId, revokedAt: null },
31
38
  );
32
39
 
40
+ // Lightweight append alongside the direct-write above (#1559) — see
41
+ // revoke.write.ts for why this isn't a lifecycle event on
42
+ // store_user_sessions. Uses the low-level append() (not
43
+ // ctx.unsafeAppendEvent) because this handler is deliberately
44
+ // cross-tenant: the privileged caller's own tenantId (what
45
+ // ctx.unsafeAppendEvent would bind to) has no relationship to the
46
+ // target user's session tenant(s). Anchored on SYSTEM_TENANT_ID
47
+ // instead, same as the jobs feature's cross-tenant run-lifecycle
48
+ // events (job-run-logger.ts) — a fresh aggregate per call, so no
49
+ // predecessor/version_conflict concerns.
50
+ //
51
+ // append() itself does NOT validate against the r.defineEvent-
52
+ // registered schema (that guarantee only comes from going through
53
+ // ctx.unsafeAppendEvent's dispatcher path). Mirrors job-run-logger.ts:
54
+ // parse explicitly before the raw append so a shape drift fails loudly
55
+ // here instead of landing unvalidated on the events table.
56
+ if (updated.length > 0) {
57
+ const payload = sessionRevokedSchema.parse({
58
+ userId: event.payload.userId,
59
+ sessionIds: updated.map((row) => row.id),
60
+ });
61
+ await append(ctx.db.raw, {
62
+ aggregateId: generateId(),
63
+ aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
64
+ tenantId: SYSTEM_TENANT_ID,
65
+ expectedVersion: 0,
66
+ type: SESSION_REVOKED_EVENT_QN,
67
+ payload,
68
+ metadata: { userId: event.user.id },
69
+ });
70
+ }
71
+
33
72
  return {
34
73
  isSuccess: true as const,
35
74
  data: { count: updated.length, userId: event.payload.userId },
@@ -1,10 +1,12 @@
1
1
  import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
4
5
  import { Temporal } from "temporal-polyfill";
5
6
  import { z } from "zod";
6
7
  import { SessionErrors } from "../constants";
7
8
  import { userSessionTable } from "../schema/user-session";
9
+ import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN } from "../session-revoked-event";
8
10
 
9
11
  // "Sign out everywhere else" — keep the caller's current session, kill all
10
12
  // other live sessions for this user. Requires `user.sid` so "keep current"
@@ -25,13 +27,29 @@ export const revokeAllOthersWrite = defineWriteHandler({
25
27
  );
26
28
  }
27
29
 
28
- const updated = await updateMany(
30
+ const updated = await updateMany<{ id: string }>(
29
31
  ctx.db,
30
32
  userSessionTable,
31
33
  { revokedAt: Temporal.Now.instant() },
32
34
  { userId: event.user.id, revokedAt: null, id: { ne: keepSid } },
33
35
  );
34
36
 
37
+ // Lightweight append alongside the direct-write above (#1559) — see
38
+ // revoke.write.ts for why this isn't a lifecycle event on
39
+ // store_user_sessions. Skipped when nothing was revoked (no other
40
+ // live sessions) — a spurious invalidation would be the failure mode.
41
+ if (updated.length > 0) {
42
+ await ctx.unsafeAppendEvent({
43
+ aggregateId: generateId(),
44
+ aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
45
+ type: SESSION_REVOKED_EVENT_QN,
46
+ payload: {
47
+ userId: event.user.id,
48
+ sessionIds: updated.map((row) => row.id),
49
+ },
50
+ });
51
+ }
52
+
35
53
  return { isSuccess: true, data: { count: updated.length } };
36
54
  },
37
55
  });
@@ -1,10 +1,12 @@
1
1
  import { fetchOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
4
5
  import { Temporal } from "temporal-polyfill";
5
6
  import { z } from "zod";
6
7
  import { SessionErrors } from "../constants";
7
8
  import { userSessionTable } from "../schema/user-session";
9
+ import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN } from "../session-revoked-event";
8
10
 
9
11
  // Revoke a single session by id (= JWT jti). Three distinguishable outcomes:
10
12
  //
@@ -36,6 +38,17 @@ export const revokeWrite = defineWriteHandler({
36
38
  );
37
39
 
38
40
  if (updated.length > 0) {
41
+ // Lightweight append alongside the direct-write above (#1559) — NOT a
42
+ // lifecycle event for store_user_sessions (that table stays an
43
+ // unmanaged direct-write store, see feature.ts). Own aggregate id per
44
+ // revoke, no predecessor to satisfy, no version_conflict against a
45
+ // concurrent revoke on a different sid.
46
+ await ctx.unsafeAppendEvent({
47
+ aggregateId: generateId(),
48
+ aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
49
+ type: SESSION_REVOKED_EVENT_QN,
50
+ payload: { userId: event.user.id, sessionIds: [event.payload.id] },
51
+ });
39
52
  return { isSuccess: true, data: { id: event.payload.id } };
40
53
  }
41
54
 
@@ -15,3 +15,10 @@ export type {
15
15
  SessionMassRevoker,
16
16
  } from "./session-callbacks";
17
17
  export { createSessionCallbacks } from "./session-callbacks";
18
+ export type { SessionRevokedPayload } from "./session-revoked-event";
19
+ export {
20
+ SESSION_REVOKED_AGGREGATE_TYPE,
21
+ SESSION_REVOKED_EVENT_QN,
22
+ SESSION_REVOKED_EVENT_SHORT,
23
+ sessionRevokedSchema,
24
+ } from "./session-revoked-event";
@@ -2,18 +2,29 @@ import type {
2
2
  AuthSessionStatus,
3
3
  SessionChecker,
4
4
  SessionCreator,
5
+ SessionMassRevoker,
5
6
  SessionMetadata,
6
7
  SessionRevoker,
7
8
  } from "@cosmicdrift/kumiko-framework/api";
9
+
10
+ export type { SessionMassRevoker } from "@cosmicdrift/kumiko-framework/api";
11
+
8
12
  import { fetchOne, insertOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
13
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
10
14
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { append } from "@cosmicdrift/kumiko-framework/event-store";
11
17
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
12
18
  import { Temporal } from "temporal-polyfill";
13
19
  import { encryptForDirectWrite } from "../shared";
14
20
  import { USER_STATUS, type UserStatus, userTable } from "../user";
15
21
  import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
16
22
  import { userSessionEntity, userSessionTable } from "./schema/user-session";
23
+ import {
24
+ SESSION_REVOKED_AGGREGATE_TYPE,
25
+ SESSION_REVOKED_EVENT_QN,
26
+ sessionRevokedSchema,
27
+ } from "./session-revoked-event";
17
28
 
18
29
  // Locked accounts whose live sessions must be refused. deletionRequested is
19
30
  // intentionally absent — it's a reversible grace period and the user needs
@@ -28,11 +39,6 @@ const BLOCKED_STATUSES: ReadonlySet<UserStatus> = new Set([
28
39
  // login and every request. The (createdAt/revokedAt/ip/userAgent) columns
29
40
  // already are the audit trail — a dispatcher roundtrip buys nothing.
30
41
 
31
- // Mass-revoke for a single user. Used by the password-change hook and
32
- // "sign out everywhere" flows. Returns the count of rows flipped so a
33
- // caller can log "revoked N other sessions".
34
- export type SessionMassRevoker = (userId: string) => Promise<number>;
35
-
36
42
  // "Sign out everywhere else, keep this one" — used both by the user-facing
37
43
  // revoke-all-others handler and by other features (auth-mfa) that need the
38
44
  // same effect from a raw callback instead of a dispatcher round-trip.
@@ -144,6 +150,31 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
144
150
  { revokedAt: Temporal.Now.instant() },
145
151
  { userId, revokedAt: null },
146
152
  );
153
+
154
+ // Lightweight append alongside the direct-write above, same pattern
155
+ // as revoke.write.ts (#1559) — this callback is the password-change
156
+ // auto-revoke path (sessions/feature.ts postSave hook) and has no
157
+ // dispatcher ctx to call unsafeAppendEvent from, so it uses the raw
158
+ // append() like revoke-all-for-user.write.ts does. Without this, the
159
+ // access-invalidation consumer (#1560) never hears about a password
160
+ // change and an already-open SSE stream survives it — the exact
161
+ // "stale JWT mid-stream" gap this feature exists to close.
162
+ if (result.length > 0) {
163
+ const payload = sessionRevokedSchema.parse({
164
+ userId,
165
+ sessionIds: result.map((row: { id: string }) => row.id),
166
+ });
167
+ await append(db, {
168
+ aggregateId: generateId(),
169
+ aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
170
+ tenantId: SYSTEM_TENANT_ID,
171
+ expectedVersion: 0,
172
+ type: SESSION_REVOKED_EVENT_QN,
173
+ payload,
174
+ metadata: { userId },
175
+ });
176
+ }
177
+
147
178
  return result.length;
148
179
  },
149
180
 
@@ -156,6 +187,28 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
156
187
  ? { userId, revokedAt: null, id: { ne: currentSid } }
157
188
  : { userId, revokedAt: null },
158
189
  );
190
+
191
+ // Same reasoning as sessionMassRevoker above — this raw callback
192
+ // (used by auth-mfa and other internal callers, distinct from the
193
+ // user-facing revoke-all-others.write.ts handler which already
194
+ // appends this event) needs its own append so callers reached
195
+ // through here also cut open SSE streams.
196
+ if (result.length > 0) {
197
+ const payload = sessionRevokedSchema.parse({
198
+ userId,
199
+ sessionIds: result.map((row: { id: string }) => row.id),
200
+ });
201
+ await append(db, {
202
+ aggregateId: generateId(),
203
+ aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
204
+ tenantId: SYSTEM_TENANT_ID,
205
+ expectedVersion: 0,
206
+ type: SESSION_REVOKED_EVENT_QN,
207
+ payload,
208
+ metadata: { userId },
209
+ });
210
+ }
211
+
159
212
  return result.length;
160
213
  },
161
214
  };
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+
3
+ // Lightweight custom event appended alongside the direct-write
4
+ // updateMany() in revoke.write.ts / revoke-all-others.write.ts /
5
+ // revoke-all-for-user.write.ts (#1559). store_user_sessions itself stays
6
+ // an unmanaged direct-write store (see feature.ts) — the row columns are
7
+ // the audit trail, this event is NOT a CRUD/lifecycle event for that
8
+ // table and carries no projection. Its only job is to give cross-instance
9
+ // consumers (access-invalidation, #1560) something to LISTEN for via the
10
+ // existing event-store NOTIFY, keyed by the affected userId.
11
+ //
12
+ // _SHORT/_QN pair mirrors cap-counter's rolling-incremented event:
13
+ // _SHORT — r.defineEvent(short, schema) in feature.ts, framework prefixes
14
+ // it to the QN below.
15
+ // _QN — ctx.unsafeAppendEvent({ type }) / raw append()'s `type` field.
16
+ export const SESSION_REVOKED_EVENT_SHORT = "session-revoked" as const;
17
+ export const SESSION_REVOKED_EVENT_QN = "sessions:event:session-revoked" as const;
18
+
19
+ // Own aggregate type, decoupled from userSessionEntity's implicit
20
+ // "user-session" — each revoke action mints a fresh aggregate id (no
21
+ // predecessor to satisfy, no version_conflict against concurrent revokes).
22
+ export const SESSION_REVOKED_AGGREGATE_TYPE = "user-session-revocation" as const;
23
+
24
+ export const sessionRevokedSchema = z.object({
25
+ userId: z.string().min(1),
26
+ sessionIds: z.array(z.uuid()).min(1),
27
+ });
28
+
29
+ export type SessionRevokedPayload = z.infer<typeof sessionRevokedSchema>;
@@ -98,10 +98,10 @@ describe("runChunkedMigration", () => {
98
98
  deadlineAt: Number.POSITIVE_INFINITY,
99
99
  });
100
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);
101
+ // Batch 1 processes "a" and pushes failed to 1; the breaker is checked
102
+ // before batchesProcessed is incremented for the next fetched batch, so
103
+ // "b" is fetched but never touched and doesn't count as processed.
104
+ expect(result.batchesProcessed).toBe(1);
105
105
  expect(result.stoppedReason).toBe("too_many_failures");
106
106
  });
107
107
 
@@ -59,13 +59,13 @@ export async function runChunkedMigration<Row>(
59
59
  const batch = await opts.nextBatch();
60
60
  if (batch.length === 0) break;
61
61
 
62
- batchesProcessed++;
63
-
64
62
  if (failed >= opts.maxFailures) {
65
63
  stoppedReason = "too_many_failures";
66
64
  break;
67
65
  }
68
66
 
67
+ batchesProcessed++;
68
+
69
69
  for (const row of batch) {
70
70
  if (failed >= opts.maxFailures) {
71
71
  stoppedReason = "too_many_failures";
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { withoutAmbientTemporal } from "@cosmicdrift/kumiko-framework/testing";
3
+ import { Temporal as TemporalPolyfill } from "temporal-polyfill";
4
+ import { isWithinGracePeriod } from "./grace-period";
5
+
6
+ describe("isWithinGracePeriod — kumiko-framework#1525/#1550", () => {
7
+ test("null gracePeriodEnd → false without ambient Temporal", async () => {
8
+ await withoutAmbientTemporal(() => {
9
+ expect(isWithinGracePeriod(null)).toBe(false);
10
+ });
11
+ });
12
+
13
+ test("future vs past without ambient Temporal", async () => {
14
+ const future = TemporalPolyfill.Now.instant().add({
15
+ hours: 1,
16
+ }) as unknown as Temporal.Instant;
17
+ const past = TemporalPolyfill.Now.instant().subtract({
18
+ hours: 1,
19
+ }) as unknown as Temporal.Instant;
20
+
21
+ await withoutAmbientTemporal(() => {
22
+ expect(isWithinGracePeriod(future)).toBe(true);
23
+ expect(isWithinGracePeriod(past)).toBe(false);
24
+ });
25
+ });
26
+ });
@@ -0,0 +1,20 @@
1
+ /** Shared grace-period check for user-data-rights + tenant-lifecycle cancel flows. */
2
+
3
+ import { Temporal as TemporalPolyfill } from "temporal-polyfill";
4
+
5
+ /**
6
+ * True when `gracePeriodEnd` is still in the future.
7
+ * Uses the polyfill Instant API so callers don't depend on `globalThis.Temporal`.
8
+ */
9
+ export function isWithinGracePeriod(gracePeriodEnd: Temporal.Instant | null): boolean {
10
+ // @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
11
+ // at runtime — row types resolve against ambient Temporal; polyfill Instant
12
+ // is a separate nominal type across the two .d.ts sources.
13
+ return (
14
+ gracePeriodEnd != null &&
15
+ TemporalPolyfill.Instant.compare(
16
+ gracePeriodEnd as unknown as InstanceType<typeof TemporalPolyfill.Instant>,
17
+ TemporalPolyfill.Now.instant(),
18
+ ) > 0
19
+ );
20
+ }
@@ -8,7 +8,9 @@ export {
8
8
  export { decryptStoredPii } from "./decrypt-stored-pii";
9
9
  export { encryptForDirectWrite } from "./encrypt-for-direct-write";
10
10
  export { entitiesOf } from "./entities-of";
11
+ export { isWithinGracePeriod } from "./grace-period";
11
12
  export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
13
+ export { mapWithConcurrency } from "./map-with-concurrency";
12
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
13
15
  export type { SystemQueryFn } from "./system-query";
14
16
  export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
@@ -0,0 +1,104 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mapWithConcurrency } from "./map-with-concurrency";
3
+
4
+ describe("mapWithConcurrency", () => {
5
+ test("preserves result order regardless of completion order", async () => {
6
+ const delays = [30, 10, 20, 0];
7
+ const results = await mapWithConcurrency(delays, 4, async (delay, index) => {
8
+ await new Promise((resolve) => setTimeout(resolve, delay));
9
+ return index;
10
+ });
11
+ expect(results).toEqual([0, 1, 2, 3]);
12
+ });
13
+
14
+ test("never runs more than `limit` calls concurrently", async () => {
15
+ let active = 0;
16
+ let maxActive = 0;
17
+ const items = Array.from({ length: 12 }, (_, i) => i);
18
+ await mapWithConcurrency(items, 4, async (item) => {
19
+ active++;
20
+ maxActive = Math.max(maxActive, active);
21
+ await new Promise((resolve) => setTimeout(resolve, 5));
22
+ active--;
23
+ return item;
24
+ });
25
+ expect(maxActive).toBeLessThanOrEqual(4);
26
+ expect(maxActive).toBeGreaterThan(1);
27
+ });
28
+
29
+ test("propagates the first rejection", async () => {
30
+ const items = [1, 2, 3];
31
+ await expect(
32
+ mapWithConcurrency(items, 4, async (item) => {
33
+ if (item === 2) throw new Error("boom");
34
+ return item;
35
+ }),
36
+ ).rejects.toThrow("boom");
37
+ });
38
+
39
+ // Two workers rejecting at different times, under 4-way concurrency —
40
+ // regression guard for the #1398 hazard (an eagerly-fired, never-awaited
41
+ // Promise surfacing as an unattributed unhandled rejection). Every
42
+ // worker promise here is an element of the internal `Promise.all` array,
43
+ // so both rejections are always handled even though the aggregate
44
+ // `mapWithConcurrency` call already settled on the first one.
45
+ test("a second rejection after the first doesn't surface as an unhandled rejection", async () => {
46
+ const unhandled: unknown[] = [];
47
+ const onUnhandledRejection = (reason: unknown): void => {
48
+ unhandled.push(reason);
49
+ };
50
+ process.on("unhandledRejection", onUnhandledRejection);
51
+ try {
52
+ const items = [1, 2, 3, 4, 5, 6, 7, 8];
53
+ await expect(
54
+ mapWithConcurrency(items, 4, async (item) => {
55
+ if (item === 2) {
56
+ await new Promise((resolve) => setTimeout(resolve, 5));
57
+ throw new Error("boom-early");
58
+ }
59
+ if (item === 5) {
60
+ await new Promise((resolve) => setTimeout(resolve, 20));
61
+ throw new Error("boom-late");
62
+ }
63
+ await new Promise((resolve) => setTimeout(resolve, 10));
64
+ return item;
65
+ }),
66
+ ).rejects.toThrow("boom-early");
67
+ // Give the later-rejecting worker's promise a chance to settle and
68
+ // for a real unhandled rejection (if any) to fire.
69
+ await new Promise((resolve) => setTimeout(resolve, 50));
70
+ } finally {
71
+ process.off("unhandledRejection", onUnhandledRejection);
72
+ }
73
+ expect(unhandled).toEqual([]);
74
+ });
75
+
76
+ test("does not claim new items after the first rejection", async () => {
77
+ const started: number[] = [];
78
+ const items = Array.from({ length: 20 }, (_, i) => i);
79
+ await expect(
80
+ mapWithConcurrency(items, 2, async (item) => {
81
+ started.push(item);
82
+ if (item === 0) throw new Error("boom");
83
+ // Slow enough that without fail-fast, more workers would drain the list.
84
+ await new Promise((resolve) => setTimeout(resolve, 30));
85
+ return item;
86
+ }),
87
+ ).rejects.toThrow("boom");
88
+ // At most `limit` in-flight when the first throw sets `failed`; without
89
+ // the flag the other worker would keep claiming the rest of the 20.
90
+ expect(started.length).toBeLessThan(items.length);
91
+ expect(started.length).toBeLessThanOrEqual(3);
92
+ });
93
+
94
+ test("empty input resolves to an empty array without running the worker", async () => {
95
+ const results = await mapWithConcurrency<number, number>([], 4, async (item) => item);
96
+ expect(results).toEqual([]);
97
+ });
98
+
99
+ test("limit larger than item count doesn't over-spawn workers", async () => {
100
+ const items = [1, 2];
101
+ const results = await mapWithConcurrency(items, 10, async (item) => item * 2);
102
+ expect(results).toEqual([2, 4]);
103
+ });
104
+ });