@cosmicdrift/kumiko-bundled-features 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 (259) 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 +17 -5
  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/manifest-deny-qns.d.ts +3 -0
  9. package/dist/agent-tools/manifest-deny-qns.js +17 -0
  10. package/dist/agent-tools/tool-catalog.d.ts +2 -1
  11. package/dist/agent-tools/tool-catalog.js +4 -1
  12. package/dist/agent-tools/tool-dispatch.js +1 -1
  13. package/dist/audit/handlers/list.query.js +13 -8
  14. package/dist/auth-email-password/handlers/change-password.write.js +1 -0
  15. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  16. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
  17. package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
  18. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
  19. package/dist/auth-email-password/handlers/login.write.js +1 -0
  20. package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
  21. package/dist/auth-email-password/handlers/signup-confirm.write.js +6 -4
  22. package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
  23. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  24. package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
  25. package/dist/auth-email-password/index.d.ts +2 -0
  26. package/dist/auth-email-password/index.js +2 -0
  27. package/dist/auth-email-password/seeding.d.ts +2 -0
  28. package/dist/auth-email-password/seeding.js +30 -8
  29. package/dist/auth-email-password/signup-activation.d.ts +24 -0
  30. package/dist/auth-email-password/signup-activation.js +41 -0
  31. package/dist/auth-email-password/signup-token-store.d.ts +5 -0
  32. package/dist/auth-email-password/signup-token-store.js +8 -3
  33. package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
  34. package/dist/auth-email-password/web/auth-gate.js +5 -5
  35. package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
  36. package/dist/auth-email-password/web/auth-redirect.js +14 -0
  37. package/dist/auth-email-password/web/login-screen.js +2 -2
  38. package/dist/auth-email-password/web/user-menu.js +1 -1
  39. package/dist/auth-mfa/feature.js +5 -0
  40. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
  41. package/dist/auth-mfa/handlers/verify.write.js +1 -0
  42. package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
  43. package/dist/auth-mfa/screens.js +2 -2
  44. package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
  45. package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
  46. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
  47. package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
  48. package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
  49. package/dist/billing-foundation/billing-info-query.d.ts +5 -2
  50. package/dist/billing-foundation/checkout-core.d.ts +7 -0
  51. package/dist/billing-foundation/checkout-core.js +7 -2
  52. package/dist/billing-foundation/entities.d.ts +8 -0
  53. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +7 -1
  54. package/dist/billing-foundation/projection.d.ts +8 -0
  55. package/dist/cap-counter/entity.d.ts +1 -0
  56. package/dist/cap-overview/access.js +2 -9
  57. package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -2
  58. package/dist/cap-overview/handlers/tenant-options.query.js +4 -5
  59. package/dist/channel-in-app/in-app-channel.js +1 -1
  60. package/dist/channel-in-app/tables.js +4 -2
  61. package/dist/compliance-profiles/schema/profile-selection.d.ts +10 -8
  62. package/dist/compliance-profiles-ops/handlers/tenants-missing-profile.query.js +2 -4
  63. package/dist/config/handlers/values.query.d.ts +3 -1
  64. package/dist/config/handlers/values.query.js +7 -2
  65. package/dist/config/table.d.ts +6 -0
  66. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
  67. package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
  68. package/dist/custom-fields/entity.d.ts +4 -0
  69. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
  70. package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
  71. package/dist/delivery/db/queries/address-opt-outs.js +12 -7
  72. package/dist/delivery/delivery-service.js +60 -12
  73. package/dist/delivery/public-names.js +1 -1
  74. package/dist/delivery/tables.d.ts +6 -0
  75. package/dist/delivery/unsubscribe.js +66 -14
  76. package/dist/document-ingest-foundation/entity.d.ts +4 -0
  77. package/dist/feature-toggles/feature.js +2 -3
  78. package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
  79. package/dist/feature-toggles/toggle-runtime.js +30 -6
  80. package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
  81. package/dist/feature-toggles/toggle-sync-signal.js +29 -10
  82. package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
  83. package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
  84. package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
  85. package/dist/files-tenant-data/hooks.js +61 -24
  86. package/dist/folders/entity.d.ts +8 -0
  87. package/dist/folders/feature.js +2 -10
  88. package/dist/folders/handlers/delete-folder.write.js +31 -4
  89. package/dist/form-draft/db/queries/cleanup.d.ts +1 -1
  90. package/dist/form-draft/db/queries/cleanup.js +4 -3
  91. package/dist/form-draft/entity.d.ts +2 -0
  92. package/dist/form-draft/handlers/cleanup.job.d.ts +5 -1
  93. package/dist/form-draft/handlers/cleanup.job.js +11 -5
  94. package/dist/foundation-shared/index.d.ts +1 -1
  95. package/dist/foundation-shared/index.js +1 -1
  96. package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
  97. package/dist/foundation-shared/mail-host-policy.js +13 -4
  98. package/dist/inbound-mail-foundation/entities.d.ts +25 -0
  99. package/dist/inbound-mail-foundation/projection.d.ts +20 -0
  100. package/dist/inbound-provider-imap/feature.js +2 -0
  101. package/dist/inbound-provider-imap/imap-client.js +3 -4
  102. package/dist/jobs/constants.d.ts +1 -0
  103. package/dist/jobs/constants.js +1 -0
  104. package/dist/jobs/db/queries/retention.d.ts +1 -0
  105. package/dist/jobs/db/queries/retention.js +5 -0
  106. package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
  107. package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
  108. package/dist/jobs/feature.js +7 -1
  109. package/dist/jobs/handlers/list.query.d.ts +2 -1
  110. package/dist/jobs/handlers/list.query.js +40 -15
  111. package/dist/jobs/handlers/tenant-failures.query.js +1 -1
  112. package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
  113. package/dist/jobs/handlers/tenant-runs.query.js +72 -0
  114. package/dist/jobs/i18n.js +1 -0
  115. package/dist/jobs/index.d.ts +2 -0
  116. package/dist/jobs/index.js +1 -0
  117. package/dist/jobs/job-run-logger.d.ts +2 -2
  118. package/dist/jobs/job-run-logger.js +98 -9
  119. package/dist/jobs/job-run-table.d.ts +6 -0
  120. package/dist/jobs/tenant-job-failure-table.js +15 -2
  121. package/dist/jobs/tenant-job-run-table.d.ts +4 -0
  122. package/dist/jobs/tenant-job-run-table.js +34 -0
  123. package/dist/ledger/entity.d.ts +24 -8
  124. package/dist/ledger/entity.js +17 -5
  125. package/dist/ledger/feature.js +3 -0
  126. package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
  127. package/dist/ledger/handlers/create-transaction.write.js +1 -0
  128. package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
  129. package/dist/ledger/schedule-subject-pair.d.ts +2 -0
  130. package/dist/ledger/schedule-subject-pair.js +24 -0
  131. package/dist/ledger/schemas.js +8 -2
  132. package/dist/mail-transport-smtp/feature.d.ts +3 -4
  133. package/dist/mail-transport-smtp/feature.js +4 -13
  134. package/dist/managed-pages/feature.js +28 -10
  135. package/dist/managed-pages/table.d.ts +12 -0
  136. package/dist/notes-history/entity.d.ts +12 -0
  137. package/dist/notes-history/entity.js +4 -0
  138. package/dist/notes-history/feature.js +2 -10
  139. package/dist/personal-access-tokens/handlers/create.write.js +1 -0
  140. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
  141. package/dist/personal-access-tokens/screens.js +14 -3
  142. package/dist/secrets/table.d.ts +1 -0
  143. package/dist/seo/feature.js +28 -6
  144. package/dist/sessions/feature.d.ts +1 -0
  145. package/dist/sessions/feature.js +9 -8
  146. package/dist/sessions/handlers/detail.query.d.ts +1 -1
  147. package/dist/sessions/handlers/detail.query.js +2 -2
  148. package/dist/sessions/handlers/list.query.d.ts +1 -1
  149. package/dist/sessions/handlers/list.query.js +5 -3
  150. package/dist/sessions/schema/user-session.d.ts +4 -0
  151. package/dist/sessions/screens.d.ts +2 -2
  152. package/dist/sessions/screens.js +6 -7
  153. package/dist/shared/assert-no-where-rule-in-ownership-write.d.ts +2 -0
  154. package/dist/shared/assert-no-where-rule-in-ownership-write.js +18 -0
  155. package/dist/shared/encrypt-for-direct-write.js +10 -2
  156. package/dist/shared/index.d.ts +4 -3
  157. package/dist/shared/index.js +4 -3
  158. package/dist/shared/parent-visibility.d.ts +2 -1
  159. package/dist/shared/parent-visibility.js +21 -0
  160. package/dist/shared/rate-limited-text-response.d.ts +2 -0
  161. package/dist/shared/rate-limited-text-response.js +9 -0
  162. package/dist/shared/row-bound-grant.js +16 -2
  163. package/dist/shared/session-locale-field.d.ts +1 -0
  164. package/dist/shared/session-locale-field.js +6 -0
  165. package/dist/tags/entity.d.ts +9 -0
  166. package/dist/tags/feature.js +2 -10
  167. package/dist/template-resolver/api.d.ts +2 -2
  168. package/dist/template-resolver/handlers/list.query.d.ts +2 -0
  169. package/dist/template-resolver/handlers/list.query.js +2 -2
  170. package/dist/template-resolver/table.d.ts +48 -30
  171. package/dist/template-resolver/user-content-table.d.ts +34 -20
  172. package/dist/tenant/decrypt-tenant-names.d.ts +3 -0
  173. package/dist/tenant/decrypt-tenant-names.js +10 -0
  174. package/dist/tenant/feature.js +7 -1
  175. package/dist/tenant/handlers/me.query.js +5 -1
  176. package/dist/tenant/handlers/memberships.query.js +3 -2
  177. package/dist/tenant/handlers/tenant-directory.query.js +9 -3
  178. package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
  179. package/dist/tenant/handlers/update-member-roles.write.js +1 -0
  180. package/dist/tenant/index.d.ts +1 -0
  181. package/dist/tenant/index.js +1 -0
  182. package/dist/tenant/invitation-table.d.ts +54 -8
  183. package/dist/tenant/invitation-table.js +6 -1
  184. package/dist/tenant/invited-membership.d.ts +8 -6
  185. package/dist/tenant/invited-membership.js +28 -7
  186. package/dist/tenant/membership-table.d.ts +4 -0
  187. package/dist/tenant/schema/tenant.d.ts +18 -10
  188. package/dist/tenant/schema/tenant.js +4 -6
  189. package/dist/tenant/screens.js +1 -1
  190. package/dist/tenant-handover/move-entity-graph.js +11 -18
  191. package/dist/tenant-handover/transfer-graph.d.ts +2 -21
  192. package/dist/tenant-handover/transfer-graph.js +8 -75
  193. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  194. package/dist/tenant-lifecycle/boot-checks.js +13 -0
  195. package/dist/tenant-lifecycle/constants.d.ts +3 -0
  196. package/dist/tenant-lifecycle/constants.js +5 -0
  197. package/dist/tenant-lifecycle/events.d.ts +5 -0
  198. package/dist/tenant-lifecycle/events.js +5 -0
  199. package/dist/tenant-lifecycle/feature.js +6 -3
  200. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +10 -5
  201. package/dist/tenant-lifecycle/run-tenant-destroy.js +59 -17
  202. package/dist/tenant-lifecycle/stages.d.ts +8 -1
  203. package/dist/tenant-lifecycle/stages.js +33 -4
  204. package/dist/tenant-lifecycle/testing.d.ts +8 -0
  205. package/dist/tenant-lifecycle/testing.js +42 -0
  206. package/dist/tier-engine/entity.d.ts +2 -0
  207. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  208. package/dist/tier-engine/tier-resolver.d.ts +2 -0
  209. package/dist/user/handlers/detail.query.js +2 -0
  210. package/dist/user/handlers/list.query.js +6 -2
  211. package/dist/user/handlers/update.write.js +1 -0
  212. package/dist/user/schema/user.d.ts +22 -8
  213. package/dist/user/seeding.d.ts +1 -0
  214. package/dist/user/seeding.js +20 -0
  215. package/dist/user-data-rights/boot-checks.js +5 -0
  216. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +2 -2
  217. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
  218. package/dist/user-data-rights/index.d.ts +1 -0
  219. package/dist/user-data-rights/index.js +1 -0
  220. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
  221. package/dist/user-data-rights/schema/download-attempt.js +17 -9
  222. package/dist/user-data-rights/schema/download-token.d.ts +8 -0
  223. package/dist/user-data-rights/schema/export-job.d.ts +16 -8
  224. package/dist/user-profile/handlers/change-email.write.js +8 -2
  225. package/dist/waitlist/entity.d.ts +22 -6
  226. package/dist/workflow-runner/event-trigger.d.ts +1 -0
  227. package/dist/workflow-runner/event-trigger.js +63 -15
  228. package/dist/workflow-runner/handlers/resume-run.write.js +8 -2
  229. package/dist/workflow-runner/runner.d.ts +2 -1
  230. package/dist/workflow-runner/runner.js +5 -1
  231. package/package.json +35 -10
  232. package/src/agent-tools/changes.json +12 -0
  233. package/src/audit/changes.json +6 -0
  234. package/src/auth-email-password/changes.json +24 -0
  235. package/src/auth-mfa/changes.json +7 -0
  236. package/src/billing-foundation/changes.json +6 -0
  237. package/src/channel-in-app/changes.json +8 -1
  238. package/src/crypto-shredding/changes.json +12 -0
  239. package/src/delivery/changes.json +6 -0
  240. package/src/files/changes.json +24 -0
  241. package/src/files-tenant-data/changes.json +5 -0
  242. package/src/folders/changes.json +6 -0
  243. package/src/form-draft/changes.json +7 -1
  244. package/src/inbound-provider-imap/changes.json +6 -0
  245. package/src/jobs/changes.json +30 -0
  246. package/src/ledger/changes.json +23 -0
  247. package/src/managed-pages/changes.json +6 -0
  248. package/src/notes-history/changes.json +13 -0
  249. package/src/seo/changes.json +5 -0
  250. package/src/template-resolver/changes.json +5 -0
  251. package/src/tenant/changes.json +19 -0
  252. package/src/tenant-handover/changes.json +12 -0
  253. package/src/tenant-lifecycle/changes.json +22 -0
  254. package/src/tier-engine/changes.json +5 -0
  255. package/src/user/changes.json +15 -0
  256. package/src/user-data-rights/changes.json +6 -0
  257. package/src/workflow-runner/changes.json +5 -0
  258. package/dist/shared/has-where-rule.d.ts +0 -2
  259. package/dist/shared/has-where-rule.js +0 -10
@@ -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
  });
@@ -3,6 +3,7 @@ export declare const fieldDefinitionEntity: import("@cosmicdrift/kumiko-types/fi
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -24,6 +25,7 @@ export declare const fieldDefinitionEntity: import("@cosmicdrift/kumiko-types/fi
24
25
  readonly type: "text";
25
26
  readonly description?: string;
26
27
  readonly maxLength?: number;
28
+ readonly minLength?: number;
27
29
  readonly required?: boolean;
28
30
  readonly searchable?: boolean;
29
31
  readonly sortable?: boolean;
@@ -45,6 +47,7 @@ export declare const fieldDefinitionEntity: import("@cosmicdrift/kumiko-types/fi
45
47
  readonly type: "text";
46
48
  readonly description?: string;
47
49
  readonly maxLength?: number;
50
+ readonly minLength?: number;
48
51
  readonly required?: boolean;
49
52
  readonly searchable?: boolean;
50
53
  readonly sortable?: boolean;
@@ -91,6 +94,7 @@ export declare const fieldDefinitionEntity: import("@cosmicdrift/kumiko-types/fi
91
94
  readonly type: "text";
92
95
  readonly description?: string;
93
96
  readonly maxLength?: number;
97
+ readonly minLength?: number;
94
98
  readonly required?: boolean;
95
99
  readonly searchable?: boolean;
96
100
  readonly sortable?: boolean;
@@ -3,6 +3,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -40,6 +41,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
40
41
  readonly type: "text";
41
42
  readonly description?: string;
42
43
  readonly maxLength?: number;
44
+ readonly minLength?: number;
43
45
  readonly required?: boolean;
44
46
  readonly searchable?: boolean;
45
47
  readonly sortable?: boolean;
@@ -63,6 +65,7 @@ export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-f
63
65
  readonly type: "text";
64
66
  readonly description?: string;
65
67
  readonly maxLength?: number;
68
+ readonly minLength?: number;
66
69
  readonly required?: boolean;
67
70
  readonly searchable?: boolean;
68
71
  readonly sortable?: boolean;
@@ -100,6 +103,7 @@ export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-f
100
103
  readonly type: "text";
101
104
  readonly description?: string;
102
105
  readonly maxLength?: number;
106
+ readonly minLength?: number;
103
107
  readonly required?: boolean;
104
108
  readonly searchable?: boolean;
105
109
  readonly sortable?: boolean;
@@ -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 };
@@ -8,7 +8,7 @@ export const DeliveryHandlers = {
8
8
  resubscribeAddress: "delivery:write:resubscribe-address",
9
9
  resubscribeUser: "delivery:write:resubscribe-user",
10
10
  };
11
- // Fixed so links mailed out today keep working — the unsubscribe route is
11
+ // Stable path: links signed against it keep working. The unsubscribe route is
12
12
  // mounted at this exact path via `extraRoutes: [...createUnsubscribeRoutes(...)]`.
13
13
  export const DELIVERY_UNSUBSCRIBE_PATH = "/api/delivery/unsubscribe";
14
14
  // Mounted alongside the unsubscribe route by the same
@@ -6,6 +6,7 @@ export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-t
6
6
  readonly type: "text";
7
7
  readonly description?: string;
8
8
  readonly maxLength?: number;
9
+ readonly minLength?: number;
9
10
  readonly required?: boolean;
10
11
  readonly searchable?: boolean;
11
12
  readonly sortable?: boolean;
@@ -27,6 +28,7 @@ export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-t
27
28
  readonly type: "text";
28
29
  readonly description?: string;
29
30
  readonly maxLength?: number;
31
+ readonly minLength?: number;
30
32
  readonly required?: boolean;
31
33
  readonly searchable?: boolean;
32
34
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-t
48
50
  readonly type: "text";
49
51
  readonly description?: string;
50
52
  readonly maxLength?: number;
53
+ readonly minLength?: number;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -75,6 +78,7 @@ export declare const notificationAddressOptOutEntity: import("@cosmicdrift/kumik
75
78
  readonly type: "text";
76
79
  readonly description?: string;
77
80
  readonly maxLength?: number;
81
+ readonly minLength?: number;
78
82
  readonly required?: boolean;
79
83
  readonly searchable?: boolean;
80
84
  readonly sortable?: boolean;
@@ -96,6 +100,7 @@ export declare const notificationAddressOptOutEntity: import("@cosmicdrift/kumik
96
100
  readonly type: "text";
97
101
  readonly description?: string;
98
102
  readonly maxLength?: number;
103
+ readonly minLength?: number;
99
104
  readonly required?: boolean;
100
105
  readonly searchable?: boolean;
101
106
  readonly sortable?: boolean;
@@ -117,6 +122,7 @@ export declare const notificationAddressOptOutEntity: import("@cosmicdrift/kumik
117
122
  readonly type: "text";
118
123
  readonly description?: string;
119
124
  readonly maxLength?: number;
125
+ readonly minLength?: number;
120
126
  readonly required?: boolean;
121
127
  readonly searchable?: boolean;
122
128
  readonly sortable?: boolean;