@cosmicdrift/kumiko-bundled-features 0.353.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 (181) hide show
  1. package/dist/agent-tools/agent-doc-lint.d.ts +2 -0
  2. package/dist/agent-tools/agent-doc-lint.js +61 -1
  3. package/dist/agent-tools/agent-manifest.d.ts +1 -0
  4. package/dist/agent-tools/agent-manifest.js +9 -3
  5. package/dist/agent-tools/feature.js +7 -2
  6. package/dist/agent-tools/index.d.ts +1 -1
  7. package/dist/agent-tools/index.js +1 -1
  8. package/dist/agent-tools/tool-dispatch.js +1 -1
  9. package/dist/auth-email-password/handlers/change-password.write.js +1 -0
  10. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  11. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
  12. package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
  13. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
  14. package/dist/auth-email-password/handlers/login.write.js +1 -0
  15. package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
  16. package/dist/auth-email-password/handlers/signup-confirm.write.js +4 -1
  17. package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
  18. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  19. package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
  20. package/dist/auth-email-password/index.d.ts +2 -0
  21. package/dist/auth-email-password/index.js +2 -0
  22. package/dist/auth-email-password/seeding.d.ts +2 -0
  23. package/dist/auth-email-password/seeding.js +30 -8
  24. package/dist/auth-email-password/signup-activation.d.ts +24 -0
  25. package/dist/auth-email-password/signup-activation.js +41 -0
  26. package/dist/auth-email-password/signup-token-store.d.ts +5 -0
  27. package/dist/auth-email-password/signup-token-store.js +8 -3
  28. package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
  29. package/dist/auth-email-password/web/auth-gate.js +5 -5
  30. package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
  31. package/dist/auth-email-password/web/auth-redirect.js +14 -0
  32. package/dist/auth-email-password/web/login-screen.js +2 -2
  33. package/dist/auth-email-password/web/user-menu.js +1 -1
  34. package/dist/auth-mfa/feature.js +5 -0
  35. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
  36. package/dist/auth-mfa/handlers/verify.write.js +1 -0
  37. package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
  38. package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
  39. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
  40. package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
  41. package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
  42. package/dist/billing-foundation/billing-info-query.d.ts +5 -2
  43. package/dist/channel-in-app/in-app-channel.js +1 -1
  44. package/dist/channel-in-app/tables.js +4 -2
  45. package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -0
  46. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
  47. package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
  48. package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
  49. package/dist/delivery/db/queries/address-opt-outs.js +12 -7
  50. package/dist/delivery/delivery-service.js +60 -12
  51. package/dist/delivery/unsubscribe.js +66 -14
  52. package/dist/feature-toggles/feature.js +2 -3
  53. package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
  54. package/dist/feature-toggles/toggle-runtime.js +30 -6
  55. package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
  56. package/dist/feature-toggles/toggle-sync-signal.js +29 -10
  57. package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
  58. package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
  59. package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
  60. package/dist/files-tenant-data/hooks.js +58 -22
  61. package/dist/folders/handlers/delete-folder.write.js +31 -4
  62. package/dist/foundation-shared/index.d.ts +1 -1
  63. package/dist/foundation-shared/index.js +1 -1
  64. package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
  65. package/dist/foundation-shared/mail-host-policy.js +13 -4
  66. package/dist/inbound-provider-imap/feature.js +2 -0
  67. package/dist/inbound-provider-imap/imap-client.js +3 -4
  68. package/dist/jobs/constants.d.ts +1 -0
  69. package/dist/jobs/constants.js +1 -0
  70. package/dist/jobs/db/queries/retention.d.ts +1 -0
  71. package/dist/jobs/db/queries/retention.js +5 -0
  72. package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
  73. package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
  74. package/dist/jobs/feature.js +7 -1
  75. package/dist/jobs/handlers/list.query.js +1 -1
  76. package/dist/jobs/handlers/tenant-failures.query.js +1 -1
  77. package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
  78. package/dist/jobs/handlers/tenant-runs.query.js +72 -0
  79. package/dist/jobs/index.d.ts +2 -0
  80. package/dist/jobs/index.js +1 -0
  81. package/dist/jobs/job-run-logger.d.ts +2 -2
  82. package/dist/jobs/job-run-logger.js +98 -9
  83. package/dist/jobs/tenant-job-failure-table.js +15 -2
  84. package/dist/jobs/tenant-job-run-table.d.ts +4 -0
  85. package/dist/jobs/tenant-job-run-table.js +34 -0
  86. package/dist/ledger/entity.d.ts +3 -0
  87. package/dist/ledger/entity.js +13 -5
  88. package/dist/ledger/feature.js +3 -0
  89. package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
  90. package/dist/ledger/handlers/create-transaction.write.js +1 -0
  91. package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
  92. package/dist/ledger/schedule-subject-pair.d.ts +2 -0
  93. package/dist/ledger/schedule-subject-pair.js +24 -0
  94. package/dist/mail-transport-smtp/feature.d.ts +3 -4
  95. package/dist/mail-transport-smtp/feature.js +4 -13
  96. package/dist/notes-history/entity.js +4 -0
  97. package/dist/personal-access-tokens/handlers/create.write.js +1 -0
  98. package/dist/sessions/feature.d.ts +1 -0
  99. package/dist/sessions/feature.js +9 -8
  100. package/dist/sessions/handlers/detail.query.d.ts +1 -1
  101. package/dist/sessions/handlers/detail.query.js +2 -2
  102. package/dist/sessions/handlers/list.query.d.ts +1 -1
  103. package/dist/sessions/handlers/list.query.js +5 -3
  104. package/dist/sessions/screens.d.ts +2 -2
  105. package/dist/sessions/screens.js +6 -7
  106. package/dist/shared/encrypt-for-direct-write.js +10 -2
  107. package/dist/shared/index.d.ts +2 -2
  108. package/dist/shared/index.js +2 -2
  109. package/dist/shared/parent-visibility.d.ts +2 -1
  110. package/dist/shared/parent-visibility.js +21 -0
  111. package/dist/shared/row-bound-grant.js +16 -2
  112. package/dist/shared/session-locale-field.d.ts +1 -0
  113. package/dist/shared/session-locale-field.js +6 -0
  114. package/dist/template-resolver/api.d.ts +2 -2
  115. package/dist/template-resolver/handlers/list.query.d.ts +2 -0
  116. package/dist/template-resolver/handlers/list.query.js +2 -2
  117. package/dist/template-resolver/table.d.ts +8 -0
  118. package/dist/template-resolver/user-content-table.d.ts +4 -0
  119. package/dist/tenant/feature.js +7 -1
  120. package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
  121. package/dist/tenant/handlers/update-member-roles.write.js +1 -0
  122. package/dist/tenant/invitation-table.d.ts +40 -0
  123. package/dist/tenant/invitation-table.js +6 -1
  124. package/dist/tenant/invited-membership.d.ts +8 -6
  125. package/dist/tenant/invited-membership.js +28 -7
  126. package/dist/tenant/schema/tenant.d.ts +2 -0
  127. package/dist/tenant-handover/transfer-graph.d.ts +2 -21
  128. package/dist/tenant-handover/transfer-graph.js +8 -75
  129. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  130. package/dist/tenant-lifecycle/boot-checks.js +13 -0
  131. package/dist/tenant-lifecycle/constants.d.ts +3 -0
  132. package/dist/tenant-lifecycle/constants.js +5 -0
  133. package/dist/tenant-lifecycle/events.d.ts +5 -0
  134. package/dist/tenant-lifecycle/events.js +5 -0
  135. package/dist/tenant-lifecycle/feature.js +5 -3
  136. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +7 -5
  137. package/dist/tenant-lifecycle/run-tenant-destroy.js +57 -17
  138. package/dist/tenant-lifecycle/stages.d.ts +6 -1
  139. package/dist/tenant-lifecycle/stages.js +26 -3
  140. package/dist/tenant-lifecycle/testing.d.ts +8 -0
  141. package/dist/tenant-lifecycle/testing.js +42 -0
  142. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  143. package/dist/user/handlers/detail.query.js +2 -0
  144. package/dist/user/handlers/list.query.js +4 -0
  145. package/dist/user/handlers/update.write.js +1 -0
  146. package/dist/user/schema/user.d.ts +2 -0
  147. package/dist/user/seeding.d.ts +1 -0
  148. package/dist/user/seeding.js +20 -0
  149. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
  150. package/dist/user-data-rights/index.d.ts +1 -0
  151. package/dist/user-data-rights/index.js +1 -0
  152. package/dist/user-data-rights/schema/download-attempt.js +17 -9
  153. package/dist/user-data-rights/schema/export-job.d.ts +2 -0
  154. package/dist/user-profile/handlers/change-email.write.js +8 -2
  155. package/dist/waitlist/entity.d.ts +2 -0
  156. package/dist/workflow-runner/event-trigger.d.ts +1 -0
  157. package/dist/workflow-runner/event-trigger.js +60 -15
  158. package/dist/workflow-runner/handlers/resume-run.write.js +5 -2
  159. package/dist/workflow-runner/runner.d.ts +2 -1
  160. package/dist/workflow-runner/runner.js +5 -1
  161. package/package.json +35 -10
  162. package/src/auth-email-password/changes.json +24 -0
  163. package/src/auth-mfa/changes.json +7 -0
  164. package/src/billing-foundation/changes.json +6 -0
  165. package/src/channel-in-app/changes.json +8 -1
  166. package/src/crypto-shredding/changes.json +12 -0
  167. package/src/delivery/changes.json +6 -0
  168. package/src/files/changes.json +24 -0
  169. package/src/files-tenant-data/changes.json +5 -0
  170. package/src/folders/changes.json +6 -0
  171. package/src/inbound-provider-imap/changes.json +6 -0
  172. package/src/jobs/changes.json +30 -0
  173. package/src/ledger/changes.json +23 -0
  174. package/src/notes-history/changes.json +13 -0
  175. package/src/template-resolver/changes.json +5 -0
  176. package/src/tenant/changes.json +12 -0
  177. package/src/tenant-handover/changes.json +6 -0
  178. package/src/tenant-lifecycle/changes.json +22 -0
  179. package/src/tier-engine/changes.json +5 -0
  180. package/src/user/changes.json +15 -0
  181. package/src/user-data-rights/changes.json +6 -0
@@ -1,11 +1,11 @@
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 { computeBlindIndex, configuredBlindIndexKey, configuredPiiSubjectKms, subjectIdSchema, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
4
5
  import { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "@cosmicdrift/kumiko-framework/db";
5
6
  import { defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import { AccessDeniedError, InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
7
- import { append } from "@cosmicdrift/kumiko-framework/event-store";
8
- import { assertIrreversibleOperationAllowed } from "@cosmicdrift/kumiko-framework/pipeline";
8
+ import { appendDomainEventCore, assertIrreversibleOperationAllowed, } from "@cosmicdrift/kumiko-framework/pipeline";
9
9
  import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
10
10
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
11
11
  import * as z from "zod";
@@ -13,7 +13,7 @@ import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
13
13
  import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
14
14
  import { USER_STATUS } from "../../user/index.js";
15
15
  import { denyIfTargetOutsideAdminTenant, isSystemAdminActor, updateUserLifecycle, } from "../../user-data-rights/index.js";
16
- import { CRYPTO_SHREDDING_AGGREGATE_TYPE, RECORD_ENTITY_NOT_REGISTERED, SUBJECT_FORGET_DENIED_EVENT_NAME, SUBJECT_FORGOTTEN_EVENT_NAME, TARGET_RECORD_NOT_ADMIN_TENANT, TARGET_RECORD_RETENTION_BLOCK_DELETE, TARGET_TENANT_NOT_ADMIN_TENANT, } from "../constants.js";
16
+ import { CRYPTO_SHREDDING_AGGREGATE_TYPE, CRYPTO_SHREDDING_FEATURE_NAME, RECORD_ENTITY_NOT_REGISTERED, SUBJECT_FORGET_DENIED_EVENT_NAME, SUBJECT_FORGOTTEN_EVENT_NAME, TARGET_RECORD_NOT_ADMIN_TENANT, TARGET_RECORD_RETENTION_BLOCK_DELETE, TARGET_TENANT_NOT_ADMIN_TENANT, } from "../constants.js";
17
17
  export { subjectIdSchema };
18
18
  export const forgetSubjectSchema = z.object({
19
19
  subject: subjectIdSchema,
@@ -23,6 +23,9 @@ export const subjectForgottenSchema = z.object({
23
23
  subjectKey: z.string().min(1),
24
24
  reason: z.string().min(10),
25
25
  forgottenBy: z.string().min(1),
26
+ // Error code or name of a failed derived-data sweep, never its message (which
27
+ // can name the subject). Present only when the handler failed after the erase.
28
+ cleanupError: z.string().min(1).optional(),
26
29
  });
27
30
  export const subjectForgetDeniedSchema = z.object({
28
31
  subjectKeyDigest: z.string().min(1).optional(),
@@ -160,14 +163,8 @@ async function appendDenialAuditEvent(ctx, event, subjectKey, subjectKind, denia
160
163
  "ctx.dbOutsideTransaction — dispatch wiring is missing the outside-tx db source.",
161
164
  }));
162
165
  }
163
- const eventDef = ctx.registry.getEvent(SUBJECT_FORGET_DENIED_EVENT_NAME);
164
- if (!eventDef) {
165
- return writeFailure(new InternalError({
166
- message: `[crypto-shredding] event "${SUBJECT_FORGET_DENIED_EVENT_NAME}" is not registered.`,
167
- }));
168
- }
169
166
  const blindIndexKey = configuredBlindIndexKey();
170
- const payload = subjectForgetDeniedSchema.parse({
167
+ const payload = {
171
168
  // The denial lands in the REQUESTING actor's own tenant-scoped stream —
172
169
  // it must never materialise the foreign subject's identifiers there. A
173
170
  // plaintext subjectKey/aggregateId would survive as a permanent record
@@ -184,30 +181,103 @@ async function appendDenialAuditEvent(ctx, event, subjectKey, subjectKind, denia
184
181
  forgottenBy: event.user.id,
185
182
  actorTenantId: event.user.tenantId,
186
183
  denial: denialCode,
187
- });
188
- const reqCtx = requestContext.get();
189
- // This event is the only proof the denial happened. Skipping
190
- // runProjectionsForEvent here is fine — nothing projects
191
- // crypto-shredding:event:forget-denied.
192
- await append(denialAuditRunner, {
193
- aggregateId: generateId(),
194
- aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
184
+ };
185
+ await appendDomainEventCore({
186
+ registry: ctx.registry,
187
+ db: denialAuditRunner,
195
188
  // MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
196
189
  // bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
197
190
  tenantId: event.user.tenantId,
198
- expectedVersion: 0,
191
+ userId: String(event.user.id),
192
+ callSiteLabel: "forget-subject denial audit",
193
+ callerFeature: CRYPTO_SHREDDING_FEATURE_NAME,
194
+ }, {
195
+ // Fresh stream per denial: no version to read, nothing to conflict with.
196
+ aggregateId: generateId(),
197
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
199
198
  type: SUBJECT_FORGET_DENIED_EVENT_NAME,
200
- eventVersion: eventDef.version,
201
199
  payload,
202
- metadata: {
203
- userId: event.user.id,
204
- ...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
205
- ...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
206
- ...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
207
- },
208
200
  });
209
201
  return null;
210
202
  }
203
+ // Name or driver code of the error, never its message: a message can carry the
204
+ // subject key or row values and would land in a permanent audit event.
205
+ function sweepErrorLabel(err) {
206
+ if (typeof err === "object" && err !== null && "code" in err) {
207
+ const code = err.code;
208
+ if (typeof code === "string" && code.length > 0)
209
+ return code;
210
+ }
211
+ return err instanceof Error && err.name.length > 0 ? err.name : "UnknownError";
212
+ }
213
+ // The key is already erased when this runs, so a failing sweep must not skip the
214
+ // audit event: the failure is returned as a label for the event instead of thrown.
215
+ // The savepoint keeps a database error from aborting the handler transaction.
216
+ async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunner) {
217
+ try {
218
+ await runInSavepointIfSupported(crossTenantRunner, async (crossTenantSubjectRunner) => {
219
+ // Blind-index sweep (#818): nulls bidx columns now so the deterministic
220
+ // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
221
+ await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
222
+ // Derived search index still holds plaintext (#1610) — purge next to the
223
+ // blind-index sweep. No adapter → no-op (apps without search).
224
+ if (ctx.searchAdapter) {
225
+ await purgeSearchDocumentsForSubject(crossTenantSubjectRunner, ctx.registry.features, ctx.searchAdapter, subjectKey, subject);
226
+ }
227
+ });
228
+ return undefined;
229
+ }
230
+ catch (err) {
231
+ return sweepErrorLabel(err);
232
+ }
233
+ }
234
+ // Graceful fail: email-subscribers and other non-user entities may use
235
+ // user-style subject keys without having an actual user row — the key erase is
236
+ // the important part for GDPR compliance; the lifecycle update is best-effort for
237
+ // real users. The savepoint confines the failure so the handler transaction
238
+ // stays usable for the audit event.
239
+ async function closeUserLoginDoorBestEffort(ctx, userId, lifecycleRunner) {
240
+ try {
241
+ await runInSavepointIfSupported(lifecycleRunner, async (userLifecycleRunner) => {
242
+ await updateUserLifecycle(userLifecycleRunner, userId, { status: USER_STATUS.Deleted });
243
+ if (ctx.registry.features.has("personal-access-tokens")) {
244
+ await revokeAllPatTokensForUser(userLifecycleRunner, userId);
245
+ }
246
+ });
247
+ }
248
+ catch {
249
+ // skip: no user row for this subject key (e.g. email subscribers); key erase and sweeps already ran.
250
+ }
251
+ }
252
+ // Through the outside-transaction db when available, so the audit event commits
253
+ // even though the handler may fail afterwards (sweep error) and roll back its own
254
+ // transaction. Without that source the event stays in the handler transaction.
255
+ async function appendSubjectForgotten(ctx, event, aggregateId, payload, outsideRunner) {
256
+ if (!outsideRunner) {
257
+ await ctx.unsafeAppendEvent({
258
+ aggregateId,
259
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
260
+ type: SUBJECT_FORGOTTEN_EVENT_NAME,
261
+ payload,
262
+ });
263
+ }
264
+ else {
265
+ await appendDomainEventCore({
266
+ registry: ctx.registry,
267
+ db: outsideRunner,
268
+ // MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw bypasses TenantDb's scoping (fw#2452).
269
+ tenantId: event.user.tenantId,
270
+ userId: String(event.user.id),
271
+ callSiteLabel: "forget-subject audit",
272
+ callerFeature: CRYPTO_SHREDDING_FEATURE_NAME,
273
+ }, {
274
+ aggregateId,
275
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
276
+ type: SUBJECT_FORGOTTEN_EVENT_NAME,
277
+ payload,
278
+ });
279
+ }
280
+ }
211
281
  // Manual crypto-shred for a DPO / platform operator: erases the subject's
212
282
  // DEK immediately (all its PII ciphertext becomes unreadable, reads render
213
283
  // "[[erased]]") and appends the audit event. Forget is final — the adapter
@@ -227,7 +297,7 @@ export const forgetSubjectWrite = defineWriteHandler({
227
297
  agent: { expose: false, risk: "high" },
228
298
  escapeHatch: {
229
299
  grants: ["unsafeRaw"],
230
- reason: "denial audit append names the prober's own tenant stream on the outside-transaction db; " +
300
+ reason: "the denial and SUBJECT_FORGOTTEN audit appends name the caller's own tenant stream on the outside-transaction db; " +
231
301
  "the tenant-scope and retention checks run against the subject's tenant, not necessarily the caller's; " +
232
302
  "the blind-index sweep and search purge address the subject across tenants; the user " +
233
303
  "lifecycle update and PAT revoke run on the SYSTEM user stream.",
@@ -270,15 +340,7 @@ export const forgetSubjectWrite = defineWriteHandler({
270
340
  userId: event.user.id,
271
341
  eraseReason: event.payload.reason,
272
342
  });
273
- // Blind-index sweep (#818): nulls bidx columns now so the deterministic
274
- // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
275
- const crossTenantSubjectRunner = ctx.db.unsafeRaw();
276
- await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
277
- // Derived search index still holds plaintext (#1610) — purge next to the
278
- // blind-index sweep. No adapter → no-op (apps without search).
279
- if (ctx.searchAdapter) {
280
- await purgeSearchDocumentsForSubject(crossTenantSubjectRunner, ctx.registry.features, ctx.searchAdapter, subjectKey, subject);
281
- }
343
+ const cleanupError = await sweepDerivedSubjectData(ctx, subject, subjectKey, ctx.db.unsafeRaw());
282
344
  // User subject: close the login door. DEK-erase makes the passwordHash
283
345
  // ciphertext unreadable, but status + PATs are standalone credentials —
284
346
  // the PAT resolver only checks revokedAt/expiresAt, NOT user.status
@@ -294,33 +356,20 @@ export const forgetSubjectWrite = defineWriteHandler({
294
356
  // crash recovery is safe. User-feature guard: without the user feature
295
357
  // read_users doesn't exist (crypto-only stack). Tenant subjects have no
296
358
  // credentials.
297
- // Graceful fail: email-subscribers and other non-user entities may use
298
- // user-style subject keys without having an actual user row — the key
299
- // erase above is the important part for GDPR compliance; the lifecycle
300
- // update is best-effort for real users.
301
359
  if (raw.kind === "user" && ctx.registry.features.has("user")) {
302
- try {
303
- const userLifecycleRunner = ctx.db.unsafeRaw();
304
- await updateUserLifecycle(userLifecycleRunner, raw.userId, { status: USER_STATUS.Deleted });
305
- if (ctx.registry.features.has("personal-access-tokens")) {
306
- await revokeAllPatTokensForUser(userLifecycleRunner, raw.userId);
307
- }
308
- }
309
- catch {
310
- // User row may not exist (e.g. email subscribers with user-style
311
- // subject keys). Key erase + blind-index sweep above already ran.
312
- }
360
+ await closeUserLoginDoorBestEffort(ctx, raw.userId, ctx.db.unsafeRaw());
361
+ }
362
+ await appendSubjectForgotten(ctx, event, subjectAggregateId(raw), {
363
+ subjectKey,
364
+ reason: event.payload.reason,
365
+ forgottenBy: event.user.id,
366
+ ...(cleanupError !== undefined && { cleanupError }),
367
+ }, ctx.dbOutsideTransaction?.unsafeRaw());
368
+ if (cleanupError !== undefined) {
369
+ throw new InternalError({
370
+ message: `[crypto-shredding] forget-subject erased the key but a derived-data sweep failed (${cleanupError}); retry to finish the cleanup.`,
371
+ });
313
372
  }
314
- await ctx.unsafeAppendEvent({
315
- aggregateId: subjectAggregateId(raw),
316
- aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
317
- type: SUBJECT_FORGOTTEN_EVENT_NAME,
318
- payload: {
319
- subjectKey,
320
- reason: event.payload.reason,
321
- forgottenBy: event.user.id,
322
- },
323
- });
324
373
  return { isSuccess: true, data: { subjectKey } };
325
374
  },
326
375
  });
@@ -1,3 +1,5 @@
1
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
- export declare function isAddressOptedOut(db: DbConnection, tenantId: TenantId, addressHash: string, notificationType: string, channel: string): Promise<boolean>;
3
+ export type AddressOptOutKey = string;
4
+ export declare function addressOptOutKey(addressHash: string, channel: string): AddressOptOutKey;
5
+ export declare function selectOptedOutAddresses(db: DbConnection, tenantId: TenantId, notificationType: string, addressHashes: readonly string[]): Promise<ReadonlySet<AddressOptOutKey>>;
@@ -1,10 +1,15 @@
1
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { notificationAddressOptOutsTable } from "../../tables.js";
3
+ export function addressOptOutKey(addressHash, channel) {
4
+ return `${addressHash}|${channel}`;
5
+ }
2
6
  // Exact match only — unlike notification-preferences there is no wildcard
3
7
  // ("*") semantics for address opt-outs, the token that creates a row always
4
- // carries one concrete notificationType/channel pair.
5
- export async function isAddressOptedOut(db, tenantId, addressHash, notificationType, channel) {
6
- const rows = await unsafeReadRetrying(db, `SELECT id FROM read_notification_address_opt_outs
7
- WHERE tenant_id = $1 AND address_hash = $2 AND notification_type = $3 AND channel = $4
8
- LIMIT 1`, [tenantId, addressHash, notificationType, channel]);
9
- return rows.length > 0;
8
+ // carries one concrete notificationType/channel pair. One query covers every
9
+ // recipient of a notify() call.
10
+ export async function selectOptedOutAddresses(db, tenantId, notificationType, addressHashes) {
11
+ if (addressHashes.length === 0)
12
+ return new Set();
13
+ const rows = await selectMany(db, notificationAddressOptOutsTable, { tenantId, notificationType, addressHash: { in: addressHashes } });
14
+ return new Set(rows.map((row) => addressOptOutKey(row.addressHash, row.channel)));
10
15
  }
@@ -9,7 +9,7 @@ import { hashUnsubscribeAddress } from "./address-opt-out.js";
9
9
  import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
10
10
  import { buildChannelContext } from "./channel-context.js";
11
11
  import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants.js";
12
- import { isAddressOptedOut } from "./db/queries/address-opt-outs.js";
12
+ import { addressOptOutKey, selectOptedOutAddresses, } from "./db/queries/address-opt-outs.js";
13
13
  import { selectNotificationPreferences } from "./db/queries/preferences.js";
14
14
  import { isDeliveryChannelPlugin, } from "./types.js";
15
15
  export function redactedMessageOf(err) {
@@ -255,12 +255,54 @@ export function createDeliveryService(options) {
255
255
  }
256
256
  // Shared by deliverToUser (resolved account address) and deliverDirect (a
257
257
  // route address with no user account) — same suppression rule either way:
258
- // no blind-index key configured means no hash, so nothing to look up.
259
- async function isAddressSuppressed(address, tenantId, notificationType, channelName) {
258
+ // no blind-index key configured means no hash, so nothing to look up. One
259
+ // query per notify() call, not per recipient and channel.
260
+ async function loadSuppressedAddresses(tenantId, notificationType, priority, addresses) {
261
+ // Critical priority skips address opt-outs, same rule as user preferences.
262
+ if (priority === "critical")
263
+ return new Set();
264
+ const addressHashes = new Set();
265
+ for (const address of addresses) {
266
+ const addressHash = hashUnsubscribeAddress(address);
267
+ if (addressHash !== undefined)
268
+ addressHashes.add(addressHash);
269
+ }
270
+ return selectOptedOutAddresses(db, tenantId, notificationType, [...addressHashes]);
271
+ }
272
+ function isAddressSuppressed(suppressed, address, channelName) {
260
273
  const addressHash = hashUnsubscribeAddress(address);
261
- if (addressHash === undefined)
274
+ return addressHash !== undefined && suppressed.has(addressOptOutKey(addressHash, channelName));
275
+ }
276
+ const recipientChannelKey = (userId, channelName) => `${userId}|${channelName}`;
277
+ async function isChannelDeliverable(userId, tenantId, notificationType, channelName) {
278
+ if (isChannelKilled && (await isChannelKilled(tenantId, channelName)))
262
279
  return false;
263
- return isAddressOptedOut(db, tenantId, addressHash, notificationType, channelName);
280
+ return isChannelEnabled(userId, tenantId, notificationType, channelName);
281
+ }
282
+ // Resolves every user's channel addresses up front so the opt-out lookup can
283
+ // cover them all at once. A resolve that throws stays out of the map: the
284
+ // delivery loop resolves it again and reports channel_error as before.
285
+ // Channels the kill switch or the user's preferences disable are never resolved
286
+ // here: resolve may have side effects and deliverToUser skips those channels.
287
+ async function prefetchUserAddresses(userIds, tenantId, notificationType) {
288
+ const addresses = new Map();
289
+ const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
290
+ for (const userId of userIds) {
291
+ for (const channel of channels) {
292
+ if (!channel.resolve)
293
+ continue;
294
+ try {
295
+ if (!(await isChannelDeliverable(userId, tenantId, notificationType, channel.name))) {
296
+ continue;
297
+ }
298
+ addresses.set(recipientChannelKey(userId, channel.name), await channel.resolve(userId, channelCtx));
299
+ }
300
+ catch {
301
+ // skip: re-resolved (and logged) by deliverToUser
302
+ }
303
+ }
304
+ }
305
+ return addresses;
264
306
  }
265
307
  // Check if user has disabled this notification+channel combo.
266
308
  // Specificity order: exact > any wildcard. When only wildcards match and they
@@ -283,7 +325,7 @@ export function createDeliveryService(options) {
283
325
  // and conservative — DB ordering no longer decides the outcome).
284
326
  return !prefs.some((p) => p.enabled === false);
285
327
  }
286
- async function deliverToUser(userId, notificationType, data, tenantId, priority, run) {
328
+ async function deliverToUser(userId, notificationType, data, tenantId, priority, run, prefetchedAddresses, suppressed) {
287
329
  const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
288
330
  for (const channel of channels) {
289
331
  // Route-only channel (no per-user address): not a user-notification target.
@@ -342,7 +384,10 @@ export function createDeliveryService(options) {
342
384
  }
343
385
  }
344
386
  try {
345
- const address = await channel.resolve(userId, channelCtx);
387
+ const prefetchKey = recipientChannelKey(userId, channel.name);
388
+ const address = prefetchedAddresses.has(prefetchKey)
389
+ ? prefetchedAddresses.get(prefetchKey)
390
+ : await channel.resolve(userId, channelCtx);
346
391
  if (!address) {
347
392
  await logDelivery(run, {
348
393
  tenantId,
@@ -356,8 +401,7 @@ export function createDeliveryService(options) {
356
401
  });
357
402
  continue;
358
403
  }
359
- if (priority !== "critical" &&
360
- (await isAddressSuppressed(address, tenantId, notificationType, channel.name))) {
404
+ if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
361
405
  await logDelivery(run, {
362
406
  tenantId,
363
407
  notificationType,
@@ -402,6 +446,7 @@ export function createDeliveryService(options) {
402
446
  }
403
447
  async function deliverDirect(route, notificationType, data, tenantId, priority, recipientId, run) {
404
448
  const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
449
+ const suppressed = await loadSuppressedAddresses(tenantId, notificationType, priority, Object.values(route));
405
450
  // Direct routing skips preferences (no user account) but NOT rate limit
406
451
  // — direct sends can still be abused (webhook replays, test harnesses).
407
452
  for (const channel of channels) {
@@ -411,8 +456,7 @@ export function createDeliveryService(options) {
411
456
  continue;
412
457
  // Address opt-out (critical priority skips it, same rule as user
413
458
  // preferences).
414
- if (priority !== "critical" &&
415
- (await isAddressSuppressed(address, tenantId, notificationType, channel.name))) {
459
+ if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
416
460
  await logDelivery(run, {
417
461
  tenantId,
418
462
  notificationType,
@@ -513,8 +557,12 @@ export function createDeliveryService(options) {
513
557
  else {
514
558
  userIds = to;
515
559
  }
560
+ const prefetchedAddresses = priority === "critical"
561
+ ? new Map()
562
+ : await prefetchUserAddresses(userIds, tenantId, notificationType);
563
+ const suppressed = await loadSuppressedAddresses(tenantId, notificationType, priority, [...prefetchedAddresses.values()].filter((address) => !!address));
516
564
  for (const userId of userIds) {
517
- await deliverToUser(userId, notificationType, data, tenantId, priority, run);
565
+ await deliverToUser(userId, notificationType, data, tenantId, priority, run, prefetchedAddresses, suppressed);
518
566
  }
519
567
  }
520
568
  return { deliveries: run.deliveries };
@@ -32,6 +32,9 @@ const addressUnsubscribeJwtPayloadSchema = z.object({
32
32
  addressHash: z.string().min(1),
33
33
  });
34
34
  const UNSUBSCRIBE_EXPIRY = "7d";
35
+ const RESUBSCRIBE_EXPIRY = "1h";
36
+ const UNSUBSCRIBE_ISSUER = "kumiko:unsubscribe";
37
+ const RESUBSCRIBE_AUDIENCE = "kumiko:resubscribe";
35
38
  const MIN_UNSUBSCRIBE_SECRET_LENGTH = 32;
36
39
  function assertUnsubscribeSecret(secret, caller) {
37
40
  if (secret.length < MIN_UNSUBSCRIBE_SECRET_LENGTH) {
@@ -51,7 +54,7 @@ export async function signUnsubscribeToken(payload, secret) {
51
54
  })
52
55
  .setProtectedHeader({ alg: "HS256" })
53
56
  .setSubject(String(payload.userId))
54
- .setIssuer("kumiko:unsubscribe")
57
+ .setIssuer(UNSUBSCRIBE_ISSUER)
55
58
  .setIssuedAt()
56
59
  .setExpirationTime(UNSUBSCRIBE_EXPIRY)
57
60
  .sign(encodedSecret);
@@ -79,12 +82,10 @@ export async function signAddressUnsubscribeToken(payload, secret) {
79
82
  })
80
83
  .setProtectedHeader({ alg: "HS256" })
81
84
  .setSubject(addressHash)
82
- .setIssuer("kumiko:unsubscribe")
85
+ .setIssuer(UNSUBSCRIBE_ISSUER)
83
86
  .setIssuedAt()
84
- // No expiry, unlike the user token above: this same token also signs
85
- // the resubscribe (undo) direction, and an unsubscribe link mailed out
86
- // today must keep working indefinitely — there's no signed-in flow for
87
- // a no-account address to request a fresh link from.
87
+ // Only opt-out, so no expiry: a link mailed out today must keep working,
88
+ // there's no signed-in flow for a no-account address to request a fresh one.
88
89
  .sign(encodedSecret));
89
90
  }
90
91
  // RFC 8058 fixed value — both the List-Unsubscribe-Post header and the body a
@@ -94,18 +95,50 @@ const CONFIRMATION_PAGE_HEADERS = {
94
95
  "Cache-Control": "no-store",
95
96
  "Referrer-Policy": "no-referrer",
96
97
  };
98
+ // Undo token, handed out only in the response of a successful unsubscribe. A
99
+ // separate audience and a short expiry keep the long-lived mailed unsubscribe
100
+ // link from being able to undo an opt-out (and vice versa).
101
+ async function signResubscribeToken(verified, encodedSecret) {
102
+ const claims = {
103
+ tenantId: verified.tenantId,
104
+ notificationType: verified.notificationType,
105
+ channel: verified.channel,
106
+ };
107
+ const jwt = verified.kind === "address"
108
+ ? new jose.SignJWT({
109
+ ...claims,
110
+ addressHash: verified.addressHash,
111
+ kind: "address",
112
+ }).setSubject(verified.addressHash)
113
+ : new jose.SignJWT(claims).setSubject(verified.userId);
114
+ return jwt
115
+ .setProtectedHeader({ alg: "HS256" })
116
+ .setIssuer(UNSUBSCRIBE_ISSUER)
117
+ .setAudience(RESUBSCRIBE_AUDIENCE)
118
+ .setIssuedAt()
119
+ .setExpirationTime(RESUBSCRIBE_EXPIRY)
120
+ .sign(encodedSecret);
121
+ }
97
122
  // Every throw here must become the same ExtraRouteRejection(400,
98
123
  // unsubscribe_token_invalid) — anything else falls through to the
99
124
  // framework's generic 401 extra_route_signature_invalid mapping, which
100
125
  // would leak jose's internal error text into the response body.
101
- async function verifyUnsubscribeToken(token, encodedSecret) {
126
+ async function verifyUnsubscribeToken(token, encodedSecret, purpose) {
102
127
  try {
103
128
  if (!token) {
104
129
  throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "missing unsubscribe token");
105
130
  }
106
- const { payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
107
- issuer: "kumiko:unsubscribe",
108
- });
131
+ const { payload: verifiedPayload } = purpose === "resubscribe"
132
+ ? await jose.jwtVerify(token, encodedSecret, {
133
+ issuer: UNSUBSCRIBE_ISSUER,
134
+ audience: RESUBSCRIBE_AUDIENCE,
135
+ requiredClaims: ["exp"],
136
+ })
137
+ : await jose.jwtVerify(token, encodedSecret, { issuer: UNSUBSCRIBE_ISSUER });
138
+ // jose only checks `aud` when asked to, so an unsubscribe route would accept an undo token.
139
+ if (purpose === "unsubscribe" && verifiedPayload.aud !== undefined) {
140
+ throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "resubscribe token used as unsubscribe token");
141
+ }
109
142
  const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
110
143
  if (addressAttempt.success) {
111
144
  const parsed = addressAttempt.data;
@@ -211,6 +244,23 @@ function confirmationPage(token) {
211
244
  </body>
212
245
  </html>`;
213
246
  }
247
+ function unsubscribedPage(resubscribeToken) {
248
+ return `<!doctype html>
249
+ <html>
250
+ <head>
251
+ <meta charset="utf-8">
252
+ <meta name="robots" content="noindex">
253
+ <title>Unsubscribed</title>
254
+ </head>
255
+ <body>
256
+ <p>You have been unsubscribed.</p>
257
+ <form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
258
+ <input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
259
+ <button type="submit">Undo</button>
260
+ </form>
261
+ </body>
262
+ </html>`;
263
+ }
214
264
  // One-click clients put the token in the query string; only the
215
265
  // confirmation-page form submits it in the body.
216
266
  function tokenFromPostRequest(request) {
@@ -251,7 +301,7 @@ export function createUnsubscribeRoutes(options) {
251
301
  if (!token) {
252
302
  throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "missing unsubscribe token");
253
303
  }
254
- await verifyUnsubscribeToken(token, encodedSecret);
304
+ await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
255
305
  return { token };
256
306
  },
257
307
  handler: async (c, { token }) => c.html(confirmationPage(token), 200, CONFIRMATION_PAGE_HEADERS),
@@ -260,7 +310,7 @@ export function createUnsubscribeRoutes(options) {
260
310
  method: "POST",
261
311
  path: DELIVERY_UNSUBSCRIBE_PATH,
262
312
  entry: "signature",
263
- verify: async (request) => verifyUnsubscribeToken(tokenFromPostRequest(request), encodedSecret),
313
+ verify: async (request) => verifyUnsubscribeToken(tokenFromPostRequest(request), encodedSecret, "unsubscribe"),
264
314
  handler: async (c, verified, deps) => {
265
315
  // Token-verify passed — everything below is a legitimate write. Don't
266
316
  // swallow write-errors as "invalid token", that would mask real bugs
@@ -269,7 +319,9 @@ export function createUnsubscribeRoutes(options) {
269
319
  if (!succeeded) {
270
320
  return c.html("Unsubscribe failed", 500, { "Cache-Control": "no-store" });
271
321
  }
272
- return c.html("You have been unsubscribed.", 200, { "Cache-Control": "no-store" });
322
+ return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret)), 200, {
323
+ "Cache-Control": "no-store",
324
+ });
273
325
  },
274
326
  });
275
327
  // Undo direction: token only from the JSON/form body, never the query
@@ -279,7 +331,7 @@ export function createUnsubscribeRoutes(options) {
279
331
  method: "POST",
280
332
  path: DELIVERY_RESUBSCRIBE_PATH,
281
333
  entry: "signature",
282
- verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret),
334
+ verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret, "resubscribe"),
283
335
  handler: async (c, verified, deps) => {
284
336
  const outcome = await dispatchResubscribeWrite(verified, deps);
285
337
  if (outcome === "limit_reached") {
@@ -64,9 +64,8 @@ export function createFeatureTogglesFeature(options = {}) {
64
64
  // process's handler calls runtime.broadcastToggle, which publishes on
65
65
  // GlobalFeatureToggleRuntime's syncSignal so every process (including
66
66
  // the one that won the cursor) applies the flip to its own snapshot —
67
- // see toggle-runtime.ts and toggle-sync-signal.ts. Without a syncSignal
68
- // configured (no REDIS_URL — single-process dev/test), broadcastToggle
69
- // applies directly; there is nobody else to reach. Named "cache-sync"
67
+ // see toggle-runtime.ts and toggle-sync-signal.ts. The signal defaults to
68
+ // the cache-sync bus (Redis with REDIS_URL, process-local without). Named "cache-sync"
70
69
  // (not "projection" or "audit") because it's side-effect-only
71
70
  // infrastructure.
72
71
  //
@@ -1,16 +1,20 @@
1
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { type Registry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type ClosableCacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
3
4
  export type ToggleSyncSignal = {
4
5
  publish(featureName: string, enabled: boolean): void;
5
6
  onMessage(listener: (featureName: string, enabled: boolean) => void): void;
7
+ onResync?(listener: () => void): void;
6
8
  };
7
9
  export declare class GlobalFeatureToggleRuntime {
8
10
  private readonly db;
9
11
  private readonly registry;
10
12
  private readonly syncSignal?;
13
+ private readonly ownedBus?;
11
14
  private snapshot;
12
15
  private appliedDuringLoad;
13
- constructor(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal | undefined);
16
+ constructor(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal | undefined, ownedBus?: ClosableCacheSyncBus | undefined);
17
+ close(): Promise<void>;
14
18
  initialize(): Promise<void>;
15
19
  refresh(): Promise<void>;
16
20
  apply(featureName: string, enabled: boolean): void;