@cosmicdrift/kumiko-samples 0.352.0 → 0.354.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 (186) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +18 -2
  3. package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
  4. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +26 -7
  5. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  6. package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
  7. package/packages/bundled-features/src/agent-tools/index.ts +6 -1
  8. package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
  9. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
  10. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
  11. package/packages/bundled-features/src/audit/changes.json +6 -0
  12. package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
  13. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  14. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
  15. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
  17. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
  18. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
  19. package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
  21. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +7 -3
  22. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
  23. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  24. package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
  25. package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
  26. package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
  27. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
  28. package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
  29. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
  30. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
  31. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
  32. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  33. package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
  34. package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
  35. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
  36. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
  37. package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
  38. package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
  39. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
  40. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
  41. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +7 -8
  42. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  43. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
  44. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
  45. package/packages/bundled-features/src/cap-overview/access.ts +2 -9
  46. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
  48. package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
  49. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
  50. package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
  51. package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
  52. package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
  53. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  54. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
  55. package/packages/bundled-features/src/delivery/changes.json +6 -0
  56. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
  57. package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
  58. package/packages/bundled-features/src/delivery/public-names.ts +1 -1
  59. package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
  60. package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
  61. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
  62. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
  63. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
  64. package/packages/bundled-features/src/files/changes.json +24 -0
  65. package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
  66. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
  67. package/packages/bundled-features/src/files-tenant-data/hooks.ts +75 -25
  68. package/packages/bundled-features/src/folders/changes.json +6 -0
  69. package/packages/bundled-features/src/folders/feature.ts +2 -12
  70. package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
  71. package/packages/bundled-features/src/form-draft/changes.json +7 -1
  72. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
  73. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
  74. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  75. package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
  76. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  77. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
  78. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
  79. package/packages/bundled-features/src/jobs/changes.json +30 -0
  80. package/packages/bundled-features/src/jobs/constants.ts +1 -0
  81. package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
  82. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
  83. package/packages/bundled-features/src/jobs/feature.ts +7 -1
  84. package/packages/bundled-features/src/jobs/handlers/list.query.ts +45 -12
  85. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
  86. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
  87. package/packages/bundled-features/src/jobs/i18n.ts +1 -0
  88. package/packages/bundled-features/src/jobs/index.ts +2 -0
  89. package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
  90. package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
  91. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
  92. package/packages/bundled-features/src/ledger/changes.json +23 -0
  93. package/packages/bundled-features/src/ledger/entity.ts +17 -5
  94. package/packages/bundled-features/src/ledger/feature.ts +3 -0
  95. package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
  96. package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
  97. package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
  98. package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
  99. package/packages/bundled-features/src/ledger/schemas.ts +8 -2
  100. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
  101. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  102. package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
  103. package/packages/bundled-features/src/notes-history/changes.json +13 -0
  104. package/packages/bundled-features/src/notes-history/entity.ts +4 -0
  105. package/packages/bundled-features/src/notes-history/feature.ts +2 -12
  106. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
  107. package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
  108. package/packages/bundled-features/src/seo/changes.json +5 -0
  109. package/packages/bundled-features/src/seo/feature.ts +23 -7
  110. package/packages/bundled-features/src/sessions/feature.ts +21 -8
  111. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
  112. package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
  113. package/packages/bundled-features/src/sessions/screens.ts +10 -8
  114. package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
  115. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
  116. package/packages/bundled-features/src/shared/index.ts +4 -2
  117. package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
  118. package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
  119. package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
  120. package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
  121. package/packages/bundled-features/src/tags/feature.ts +2 -12
  122. package/packages/bundled-features/src/template-resolver/api.ts +3 -3
  123. package/packages/bundled-features/src/template-resolver/changes.json +5 -0
  124. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
  125. package/packages/bundled-features/src/tenant/changes.json +19 -0
  126. package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
  127. package/packages/bundled-features/src/tenant/feature.ts +8 -1
  128. package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
  129. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
  130. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
  131. package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
  132. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
  133. package/packages/bundled-features/src/tenant/index.ts +1 -0
  134. package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
  135. package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
  136. package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
  137. package/packages/bundled-features/src/tenant/screens.ts +1 -1
  138. package/packages/bundled-features/src/tenant-handover/changes.json +12 -0
  139. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
  140. package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
  141. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
  142. package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
  143. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
  144. package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
  145. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +14 -1
  146. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +105 -29
  147. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +64 -10
  148. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
  149. package/packages/bundled-features/src/tier-engine/changes.json +5 -0
  150. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
  151. package/packages/bundled-features/src/user/changes.json +15 -0
  152. package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
  153. package/packages/bundled-features/src/user/handlers/list.query.ts +8 -2
  154. package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
  155. package/packages/bundled-features/src/user/seeding.ts +34 -0
  156. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
  157. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  158. package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
  159. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
  160. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
  161. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  162. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
  163. package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
  164. package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
  165. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +80 -14
  166. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +11 -1
  167. package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
  168. package/samples/apps/showcase/src/features/items/feature.ts +2 -1
  169. package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
  170. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
  171. package/samples/apps/styleguide/src/features/widgets/feature.ts +131 -15
  172. package/samples/apps/styleguide/src/features/widgets/i18n.ts +40 -0
  173. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
  174. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
  175. package/samples/apps/ui-walkthrough/package.json +3 -0
  176. package/samples/apps/use-all-bundled/README.md +1 -2
  177. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  178. package/samples/apps/user-data-rights-demo/README.md +1 -3
  179. package/samples/recipes/legal-pages/README.md +3 -4
  180. package/samples/recipes/managed-pages/README.md +2 -2
  181. package/samples/recipes/public-share-token/README.md +4 -5
  182. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  183. package/samples/recipes/security-baseline/README.md +6 -0
  184. package/samples/recipes/seed-migration/README.md +0 -2
  185. package/samples/recipes/tenant-settings/README.md +1 -1
  186. package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
@@ -1,5 +1,6 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
3
+ import { runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
3
4
  import {
4
5
  computeBlindIndex,
5
6
  configuredBlindIndexKey,
@@ -31,8 +32,10 @@ import {
31
32
  type WriteFailure,
32
33
  writeFailure,
33
34
  } from "@cosmicdrift/kumiko-framework/errors";
34
- import { append } from "@cosmicdrift/kumiko-framework/event-store";
35
- import { assertIrreversibleOperationAllowed } from "@cosmicdrift/kumiko-framework/pipeline";
35
+ import {
36
+ appendDomainEventCore,
37
+ assertIrreversibleOperationAllowed,
38
+ } from "@cosmicdrift/kumiko-framework/pipeline";
36
39
  import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
37
40
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
38
41
  import * as z from "zod";
@@ -46,6 +49,7 @@ import {
46
49
  } from "../../user-data-rights/index.js";
47
50
  import {
48
51
  CRYPTO_SHREDDING_AGGREGATE_TYPE,
52
+ CRYPTO_SHREDDING_FEATURE_NAME,
49
53
  RECORD_ENTITY_NOT_REGISTERED,
50
54
  SUBJECT_FORGET_DENIED_EVENT_NAME,
51
55
  SUBJECT_FORGOTTEN_EVENT_NAME,
@@ -65,6 +69,9 @@ export const subjectForgottenSchema = z.object({
65
69
  subjectKey: z.string().min(1),
66
70
  reason: z.string().min(10),
67
71
  forgottenBy: z.string().min(1),
72
+ // Error code or name of a failed derived-data sweep, never its message (which
73
+ // can name the subject). Present only when the handler failed after the erase.
74
+ cleanupError: z.string().min(1).optional(),
68
75
  });
69
76
 
70
77
  export const subjectForgetDeniedSchema = z.object({
@@ -234,16 +241,8 @@ async function appendDenialAuditEvent(
234
241
  }),
235
242
  );
236
243
  }
237
- const eventDef = ctx.registry.getEvent(SUBJECT_FORGET_DENIED_EVENT_NAME);
238
- if (!eventDef) {
239
- return writeFailure(
240
- new InternalError({
241
- message: `[crypto-shredding] event "${SUBJECT_FORGET_DENIED_EVENT_NAME}" is not registered.`,
242
- }),
243
- );
244
- }
245
244
  const blindIndexKey = configuredBlindIndexKey();
246
- const payload = subjectForgetDeniedSchema.parse({
245
+ const payload: z.infer<typeof subjectForgetDeniedSchema> = {
247
246
  // The denial lands in the REQUESTING actor's own tenant-scoped stream —
248
247
  // it must never materialise the foreign subject's identifiers there. A
249
248
  // plaintext subjectKey/aggregateId would survive as a permanent record
@@ -260,31 +259,132 @@ async function appendDenialAuditEvent(
260
259
  forgottenBy: event.user.id,
261
260
  actorTenantId: event.user.tenantId,
262
261
  denial: denialCode,
263
- });
264
- const reqCtx = requestContext.get();
265
- // This event is the only proof the denial happened. Skipping
266
- // runProjectionsForEvent here is fine — nothing projects
267
- // crypto-shredding:event:forget-denied.
268
- await append(denialAuditRunner, {
269
- aggregateId: generateId(),
270
- aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
271
- // MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
272
- // bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
273
- tenantId: event.user.tenantId,
274
- expectedVersion: 0,
275
- type: SUBJECT_FORGET_DENIED_EVENT_NAME,
276
- eventVersion: eventDef.version,
277
- payload,
278
- metadata: {
279
- userId: event.user.id,
280
- ...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
281
- ...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
282
- ...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
262
+ };
263
+ await appendDomainEventCore(
264
+ {
265
+ registry: ctx.registry,
266
+ db: denialAuditRunner,
267
+ // MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
268
+ // bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
269
+ tenantId: event.user.tenantId,
270
+ userId: String(event.user.id),
271
+ callSiteLabel: "forget-subject denial audit",
272
+ callerFeature: CRYPTO_SHREDDING_FEATURE_NAME,
283
273
  },
284
- });
274
+ {
275
+ // Fresh stream per denial: no version to read, nothing to conflict with.
276
+ aggregateId: generateId(),
277
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
278
+ type: SUBJECT_FORGET_DENIED_EVENT_NAME,
279
+ payload,
280
+ },
281
+ );
285
282
  return null;
286
283
  }
287
284
 
285
+ // Name or driver code of the error, never its message: a message can carry the
286
+ // subject key or row values and would land in a permanent audit event.
287
+ function sweepErrorLabel(err: unknown): string {
288
+ if (typeof err === "object" && err !== null && "code" in err) {
289
+ const code = err.code;
290
+ if (typeof code === "string" && code.length > 0) return code;
291
+ }
292
+ return err instanceof Error && err.name.length > 0 ? err.name : "UnknownError";
293
+ }
294
+
295
+ // The key is already erased when this runs, so a failing sweep must not skip the
296
+ // audit event: the failure is returned as a label for the event instead of thrown.
297
+ // The savepoint keeps a database error from aborting the handler transaction.
298
+ async function sweepDerivedSubjectData(
299
+ ctx: HandlerContext,
300
+ subject: SubjectId,
301
+ subjectKey: string,
302
+ crossTenantRunner: DbRunner,
303
+ ): Promise<string | undefined> {
304
+ try {
305
+ await runInSavepointIfSupported(crossTenantRunner, async (crossTenantSubjectRunner) => {
306
+ // Blind-index sweep (#818): nulls bidx columns now so the deterministic
307
+ // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
308
+ await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
309
+
310
+ // Derived search index still holds plaintext (#1610) — purge next to the
311
+ // blind-index sweep. No adapter → no-op (apps without search).
312
+ if (ctx.searchAdapter) {
313
+ await purgeSearchDocumentsForSubject(
314
+ crossTenantSubjectRunner,
315
+ ctx.registry.features,
316
+ ctx.searchAdapter,
317
+ subjectKey,
318
+ subject,
319
+ );
320
+ }
321
+ });
322
+ return undefined;
323
+ } catch (err) {
324
+ return sweepErrorLabel(err);
325
+ }
326
+ }
327
+
328
+ // Graceful fail: email-subscribers and other non-user entities may use
329
+ // user-style subject keys without having an actual user row — the key erase is
330
+ // the important part for GDPR compliance; the lifecycle update is best-effort for
331
+ // real users. The savepoint confines the failure so the handler transaction
332
+ // stays usable for the audit event.
333
+ async function closeUserLoginDoorBestEffort(
334
+ ctx: HandlerContext,
335
+ userId: string,
336
+ lifecycleRunner: DbRunner,
337
+ ): Promise<void> {
338
+ try {
339
+ await runInSavepointIfSupported(lifecycleRunner, async (userLifecycleRunner) => {
340
+ await updateUserLifecycle(userLifecycleRunner, userId, { status: USER_STATUS.Deleted });
341
+ if (ctx.registry.features.has("personal-access-tokens")) {
342
+ await revokeAllPatTokensForUser(userLifecycleRunner, userId);
343
+ }
344
+ });
345
+ } catch {
346
+ // skip: no user row for this subject key (e.g. email subscribers); key erase and sweeps already ran.
347
+ }
348
+ }
349
+
350
+ // Through the outside-transaction db when available, so the audit event commits
351
+ // even though the handler may fail afterwards (sweep error) and roll back its own
352
+ // transaction. Without that source the event stays in the handler transaction.
353
+ async function appendSubjectForgotten(
354
+ ctx: HandlerContext,
355
+ event: WriteEvent<z.infer<typeof forgetSubjectSchema>>,
356
+ aggregateId: string,
357
+ payload: z.infer<typeof subjectForgottenSchema>,
358
+ outsideRunner: DbRunner | undefined,
359
+ ): Promise<void> {
360
+ if (!outsideRunner) {
361
+ await ctx.unsafeAppendEvent({
362
+ aggregateId,
363
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
364
+ type: SUBJECT_FORGOTTEN_EVENT_NAME,
365
+ payload,
366
+ });
367
+ } else {
368
+ await appendDomainEventCore(
369
+ {
370
+ registry: ctx.registry,
371
+ db: outsideRunner,
372
+ // MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw bypasses TenantDb's scoping (fw#2452).
373
+ tenantId: event.user.tenantId,
374
+ userId: String(event.user.id),
375
+ callSiteLabel: "forget-subject audit",
376
+ callerFeature: CRYPTO_SHREDDING_FEATURE_NAME,
377
+ },
378
+ {
379
+ aggregateId,
380
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
381
+ type: SUBJECT_FORGOTTEN_EVENT_NAME,
382
+ payload,
383
+ },
384
+ );
385
+ }
386
+ }
387
+
288
388
  // Manual crypto-shred for a DPO / platform operator: erases the subject's
289
389
  // DEK immediately (all its PII ciphertext becomes unreadable, reads render
290
390
  // "[[erased]]") and appends the audit event. Forget is final — the adapter
@@ -306,7 +406,7 @@ export const forgetSubjectWrite = defineWriteHandler({
306
406
  escapeHatch: {
307
407
  grants: ["unsafeRaw"],
308
408
  reason:
309
- "denial audit append names the prober's own tenant stream on the outside-transaction db; " +
409
+ "the denial and SUBJECT_FORGOTTEN audit appends name the caller's own tenant stream on the outside-transaction db; " +
310
410
  "the tenant-scope and retention checks run against the subject's tenant, not necessarily the caller's; " +
311
411
  "the blind-index sweep and search purge address the subject across tenants; the user " +
312
412
  "lifecycle update and PAT revoke run on the SYSTEM user stream.",
@@ -377,22 +477,12 @@ export const forgetSubjectWrite = defineWriteHandler({
377
477
  eraseReason: event.payload.reason,
378
478
  });
379
479
 
380
- // Blind-index sweep (#818): nulls bidx columns now so the deterministic
381
- // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
382
- const crossTenantSubjectRunner = ctx.db.unsafeRaw();
383
- await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
384
-
385
- // Derived search index still holds plaintext (#1610) — purge next to the
386
- // blind-index sweep. No adapter → no-op (apps without search).
387
- if (ctx.searchAdapter) {
388
- await purgeSearchDocumentsForSubject(
389
- crossTenantSubjectRunner,
390
- ctx.registry.features,
391
- ctx.searchAdapter,
392
- subjectKey,
393
- subject,
394
- );
395
- }
480
+ const cleanupError = await sweepDerivedSubjectData(
481
+ ctx,
482
+ subject,
483
+ subjectKey,
484
+ ctx.db.unsafeRaw(),
485
+ );
396
486
 
397
487
  // User subject: close the login door. DEK-erase makes the passwordHash
398
488
  // ciphertext unreadable, but status + PATs are standalone credentials —
@@ -409,33 +499,28 @@ export const forgetSubjectWrite = defineWriteHandler({
409
499
  // crash recovery is safe. User-feature guard: without the user feature
410
500
  // read_users doesn't exist (crypto-only stack). Tenant subjects have no
411
501
  // credentials.
412
- // Graceful fail: email-subscribers and other non-user entities may use
413
- // user-style subject keys without having an actual user row — the key
414
- // erase above is the important part for GDPR compliance; the lifecycle
415
- // update is best-effort for real users.
416
502
  if (raw.kind === "user" && ctx.registry.features.has("user")) {
417
- try {
418
- const userLifecycleRunner = ctx.db.unsafeRaw();
419
- await updateUserLifecycle(userLifecycleRunner, raw.userId, { status: USER_STATUS.Deleted });
420
- if (ctx.registry.features.has("personal-access-tokens")) {
421
- await revokeAllPatTokensForUser(userLifecycleRunner, raw.userId);
422
- }
423
- } catch {
424
- // User row may not exist (e.g. email subscribers with user-style
425
- // subject keys). Key erase + blind-index sweep above already ran.
426
- }
503
+ await closeUserLoginDoorBestEffort(ctx, raw.userId, ctx.db.unsafeRaw());
427
504
  }
428
505
 
429
- await ctx.unsafeAppendEvent({
430
- aggregateId: subjectAggregateId(raw),
431
- aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
432
- type: SUBJECT_FORGOTTEN_EVENT_NAME,
433
- payload: {
506
+ await appendSubjectForgotten(
507
+ ctx,
508
+ event,
509
+ subjectAggregateId(raw),
510
+ {
434
511
  subjectKey,
435
512
  reason: event.payload.reason,
436
513
  forgottenBy: event.user.id,
514
+ ...(cleanupError !== undefined && { cleanupError }),
437
515
  },
438
- });
516
+ ctx.dbOutsideTransaction?.unsafeRaw(),
517
+ );
518
+
519
+ if (cleanupError !== undefined) {
520
+ throw new InternalError({
521
+ message: `[crypto-shredding] forget-subject erased the key but a derived-data sweep failed (${cleanupError}); retry to finish the cleanup.`,
522
+ });
523
+ }
439
524
 
440
525
  return { isSuccess: true as const, data: { subjectKey } };
441
526
  },
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "breaking",
5
+ "title": "POST /resubscribe requires the undo token from the POST /unsubscribe response",
6
+ "migration": "Clients that called the resubscribe route with the unsubscribe token must send the undo token instead: read it from the hidden token field of the page POST /unsubscribe returns (aud kumiko:resubscribe, valid for 1 hour). The unsubscribe token is rejected on /resubscribe with 400, and the undo token is rejected on /unsubscribe."
7
+ },
2
8
  {
3
9
  "version": "0.352.0",
4
10
  "type": "breaking",
@@ -1,23 +1,29 @@
1
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { notificationAddressOptOutsTable } from "../../tables.js";
5
+
6
+ export type AddressOptOutKey = string;
7
+
8
+ export function addressOptOutKey(addressHash: string, channel: string): AddressOptOutKey {
9
+ return `${addressHash}|${channel}`;
10
+ }
4
11
 
5
12
  // Exact match only — unlike notification-preferences there is no wildcard
6
13
  // ("*") semantics for address opt-outs, the token that creates a row always
7
- // carries one concrete notificationType/channel pair.
8
- export async function isAddressOptedOut(
14
+ // carries one concrete notificationType/channel pair. One query covers every
15
+ // recipient of a notify() call.
16
+ export async function selectOptedOutAddresses(
9
17
  db: DbConnection,
10
18
  tenantId: TenantId,
11
- addressHash: string,
12
19
  notificationType: string,
13
- channel: string,
14
- ): Promise<boolean> {
15
- const rows = await unsafeReadRetrying<{ readonly id: string }>(
20
+ addressHashes: readonly string[],
21
+ ): Promise<ReadonlySet<AddressOptOutKey>> {
22
+ if (addressHashes.length === 0) return new Set();
23
+ const rows = await selectMany<{ readonly addressHash: string; readonly channel: string }>(
16
24
  db,
17
- `SELECT id FROM read_notification_address_opt_outs
18
- WHERE tenant_id = $1 AND address_hash = $2 AND notification_type = $3 AND channel = $4
19
- LIMIT 1`,
20
- [tenantId, addressHash, notificationType, channel],
25
+ notificationAddressOptOutsTable,
26
+ { tenantId, notificationType, addressHash: { in: addressHashes } },
21
27
  );
22
- return rows.length > 0;
28
+ return new Set(rows.map((row) => addressOptOutKey(row.addressHash, row.channel)));
23
29
  }
@@ -22,7 +22,11 @@ import { hashUnsubscribeAddress } from "./address-opt-out.js";
22
22
  import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
23
23
  import { buildChannelContext } from "./channel-context.js";
24
24
  import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants.js";
25
- import { isAddressOptedOut } from "./db/queries/address-opt-outs.js";
25
+ import {
26
+ type AddressOptOutKey,
27
+ addressOptOutKey,
28
+ selectOptedOutAddresses,
29
+ } from "./db/queries/address-opt-outs.js";
26
30
  import { selectNotificationPreferences } from "./db/queries/preferences.js";
27
31
  import {
28
32
  type ChannelContext,
@@ -411,16 +415,74 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
411
415
 
412
416
  // Shared by deliverToUser (resolved account address) and deliverDirect (a
413
417
  // route address with no user account) — same suppression rule either way:
414
- // no blind-index key configured means no hash, so nothing to look up.
415
- async function isAddressSuppressed(
418
+ // no blind-index key configured means no hash, so nothing to look up. One
419
+ // query per notify() call, not per recipient and channel.
420
+ async function loadSuppressedAddresses(
421
+ tenantId: TenantId,
422
+ notificationType: string,
423
+ priority: NotifyPriority,
424
+ addresses: Iterable<string>,
425
+ ): Promise<ReadonlySet<AddressOptOutKey>> {
426
+ // Critical priority skips address opt-outs, same rule as user preferences.
427
+ if (priority === "critical") return new Set();
428
+ const addressHashes = new Set<string>();
429
+ for (const address of addresses) {
430
+ const addressHash = hashUnsubscribeAddress(address);
431
+ if (addressHash !== undefined) addressHashes.add(addressHash);
432
+ }
433
+ return selectOptedOutAddresses(db, tenantId, notificationType, [...addressHashes]);
434
+ }
435
+
436
+ function isAddressSuppressed(
437
+ suppressed: ReadonlySet<AddressOptOutKey>,
416
438
  address: string,
439
+ channelName: string,
440
+ ): boolean {
441
+ const addressHash = hashUnsubscribeAddress(address);
442
+ return addressHash !== undefined && suppressed.has(addressOptOutKey(addressHash, channelName));
443
+ }
444
+
445
+ const recipientChannelKey = (userId: string, channelName: string) => `${userId}|${channelName}`;
446
+
447
+ async function isChannelDeliverable(
448
+ userId: string,
417
449
  tenantId: TenantId,
418
450
  notificationType: string,
419
451
  channelName: string,
420
452
  ): Promise<boolean> {
421
- const addressHash = hashUnsubscribeAddress(address);
422
- if (addressHash === undefined) return false;
423
- return isAddressOptedOut(db, tenantId, addressHash, notificationType, channelName);
453
+ if (isChannelKilled && (await isChannelKilled(tenantId, channelName))) return false;
454
+ return isChannelEnabled(userId, tenantId, notificationType, channelName);
455
+ }
456
+
457
+ // Resolves every user's channel addresses up front so the opt-out lookup can
458
+ // cover them all at once. A resolve that throws stays out of the map: the
459
+ // delivery loop resolves it again and reports channel_error as before.
460
+ // Channels the kill switch or the user's preferences disable are never resolved
461
+ // here: resolve may have side effects and deliverToUser skips those channels.
462
+ async function prefetchUserAddresses(
463
+ userIds: readonly string[],
464
+ tenantId: TenantId,
465
+ notificationType: string,
466
+ ): Promise<Map<string, string | null>> {
467
+ const addresses = new Map<string, string | null>();
468
+ const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
469
+ for (const userId of userIds) {
470
+ for (const channel of channels) {
471
+ if (!channel.resolve) continue;
472
+ try {
473
+ if (!(await isChannelDeliverable(userId, tenantId, notificationType, channel.name))) {
474
+ continue;
475
+ }
476
+ addresses.set(
477
+ recipientChannelKey(userId, channel.name),
478
+ await channel.resolve(userId, channelCtx),
479
+ );
480
+ } catch {
481
+ // skip: re-resolved (and logged) by deliverToUser
482
+ }
483
+ }
484
+ }
485
+ return addresses;
424
486
  }
425
487
 
426
488
  // Check if user has disabled this notification+channel combo.
@@ -466,6 +528,8 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
466
528
  tenantId: TenantId,
467
529
  priority: NotifyPriority,
468
530
  run: NotifyRun,
531
+ prefetchedAddresses: ReadonlyMap<string, string | null>,
532
+ suppressed: ReadonlySet<AddressOptOutKey>,
469
533
  ): Promise<void> {
470
534
  const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
471
535
 
@@ -530,7 +594,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
530
594
  }
531
595
 
532
596
  try {
533
- const address = await channel.resolve(userId, channelCtx);
597
+ const prefetchKey = recipientChannelKey(userId, channel.name);
598
+ const address = prefetchedAddresses.has(prefetchKey)
599
+ ? prefetchedAddresses.get(prefetchKey)
600
+ : await channel.resolve(userId, channelCtx);
534
601
  if (!address) {
535
602
  await logDelivery(run, {
536
603
  tenantId,
@@ -545,10 +612,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
545
612
  continue;
546
613
  }
547
614
 
548
- if (
549
- priority !== "critical" &&
550
- (await isAddressSuppressed(address, tenantId, notificationType, channel.name))
551
- ) {
615
+ if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
552
616
  await logDelivery(run, {
553
617
  tenantId,
554
618
  notificationType,
@@ -602,6 +666,12 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
602
666
  run: NotifyRun,
603
667
  ): Promise<void> {
604
668
  const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
669
+ const suppressed = await loadSuppressedAddresses(
670
+ tenantId,
671
+ notificationType,
672
+ priority,
673
+ Object.values(route),
674
+ );
605
675
 
606
676
  // Direct routing skips preferences (no user account) but NOT rate limit
607
677
  // — direct sends can still be abused (webhook replays, test harnesses).
@@ -612,10 +682,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
612
682
 
613
683
  // Address opt-out (critical priority skips it, same rule as user
614
684
  // preferences).
615
- if (
616
- priority !== "critical" &&
617
- (await isAddressSuppressed(address, tenantId, notificationType, channel.name))
618
- ) {
685
+ if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
619
686
  await logDelivery(run, {
620
687
  tenantId,
621
688
  notificationType,
@@ -729,8 +796,28 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
729
796
  userIds = to;
730
797
  }
731
798
 
799
+ const prefetchedAddresses =
800
+ priority === "critical"
801
+ ? new Map<string, string | null>()
802
+ : await prefetchUserAddresses(userIds, tenantId, notificationType);
803
+ const suppressed = await loadSuppressedAddresses(
804
+ tenantId,
805
+ notificationType,
806
+ priority,
807
+ [...prefetchedAddresses.values()].filter((address): address is string => !!address),
808
+ );
809
+
732
810
  for (const userId of userIds) {
733
- await deliverToUser(userId, notificationType, data, tenantId, priority, run);
811
+ await deliverToUser(
812
+ userId,
813
+ notificationType,
814
+ data,
815
+ tenantId,
816
+ priority,
817
+ run,
818
+ prefetchedAddresses,
819
+ suppressed,
820
+ );
734
821
  }
735
822
  }
736
823
  return { deliveries: run.deliveries };
@@ -11,7 +11,7 @@ export const DeliveryHandlers = {
11
11
  resubscribeUser: "delivery:write:resubscribe-user",
12
12
  } as const;
13
13
 
14
- // Fixed so links mailed out today keep working — the unsubscribe route is
14
+ // Stable path: links signed against it keep working. The unsubscribe route is
15
15
  // mounted at this exact path via `extraRoutes: [...createUnsubscribeRoutes(...)]`.
16
16
  export const DELIVERY_UNSUBSCRIBE_PATH = "/api/delivery/unsubscribe" as const;
17
17