@cosmicdrift/kumiko-bundled-features 0.337.1 → 0.339.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 (235) hide show
  1. package/dist/agent-tools/tool-catalog.js +37 -17
  2. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +3 -2
  3. package/dist/auth-email-password/handlers/invite-accept.write.js +3 -4
  4. package/dist/auth-email-password/handlers/invite-info.query.js +4 -1
  5. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +3 -4
  6. package/dist/auth-email-password/handlers/signup-confirm.write.js +2 -1
  7. package/dist/auth-email-password/handlers/signup-request.write.js +2 -2
  8. package/dist/auth-email-password/web/auth-gate.d.ts +12 -2
  9. package/dist/auth-email-password/web/auth-gate.js +25 -1
  10. package/dist/auth-email-password/web/auth-redirect.d.ts +5 -0
  11. package/dist/auth-email-password/web/auth-redirect.js +58 -0
  12. package/dist/auth-email-password/web/client-plugin.d.ts +8 -1
  13. package/dist/auth-email-password/web/client-plugin.js +2 -0
  14. package/dist/auth-email-password/web/index.d.ts +2 -1
  15. package/dist/auth-email-password/web/index.js +1 -0
  16. package/dist/auth-email-password/web/session.d.ts +3 -1
  17. package/dist/auth-email-password/web/session.js +8 -2
  18. package/dist/auth-mfa/feature.js +1 -1
  19. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -1
  20. package/dist/auth-mfa/handlers/enable-start-preauth.write.js +2 -1
  21. package/dist/auth-mfa/handlers/verify.write.js +1 -1
  22. package/dist/auth-mfa/mfa-code-verifier.js +1 -1
  23. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  24. package/dist/billing-foundation/aggregate-id.d.ts +2 -0
  25. package/dist/billing-foundation/aggregate-id.js +8 -0
  26. package/dist/billing-foundation/checkout-core.d.ts +3 -0
  27. package/dist/billing-foundation/checkout-core.js +3 -0
  28. package/dist/billing-foundation/constants.d.ts +38 -0
  29. package/dist/billing-foundation/constants.js +43 -0
  30. package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +21 -0
  31. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +125 -0
  32. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +32 -0
  33. package/dist/billing-foundation/consumer-protection/consent-gate.js +96 -0
  34. package/dist/billing-foundation/consumer-protection/consent-locale.d.ts +5 -0
  35. package/dist/billing-foundation/consumer-protection/consent-locale.js +17 -0
  36. package/dist/billing-foundation/consumer-protection/consent-text.d.ts +11 -0
  37. package/dist/billing-foundation/consumer-protection/consent-text.js +28 -0
  38. package/dist/billing-foundation/consumer-protection/issue-confirmation.d.ts +15 -0
  39. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +149 -0
  40. package/dist/billing-foundation/consumer-protection/register-consent.d.ts +3 -0
  41. package/dist/billing-foundation/consumer-protection/register-consent.js +63 -0
  42. package/dist/billing-foundation/consumer-protection/register-termination.d.ts +3 -0
  43. package/dist/billing-foundation/consumer-protection/register-termination.js +40 -0
  44. package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +45 -0
  45. package/dist/billing-foundation/consumer-protection/termination-mail.js +76 -0
  46. package/dist/billing-foundation/consumer-protection/termination-notify.d.ts +18 -0
  47. package/dist/billing-foundation/consumer-protection/termination-notify.js +21 -0
  48. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -0
  49. package/dist/billing-foundation/consumer-protection/termination-pages.js +268 -0
  50. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +73 -0
  51. package/dist/billing-foundation/consumer-protection/termination-record.js +230 -0
  52. package/dist/billing-foundation/consumer-protection/termination-request.d.ts +45 -0
  53. package/dist/billing-foundation/consumer-protection/termination-request.js +231 -0
  54. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +62 -0
  55. package/dist/billing-foundation/consumer-protection/termination-texts.js +168 -0
  56. package/dist/billing-foundation/events.d.ts +105 -2
  57. package/dist/billing-foundation/events.js +88 -2
  58. package/dist/billing-foundation/feature.js +6 -2
  59. package/dist/billing-foundation/handlers/billing-plans.query.js +19 -1
  60. package/dist/billing-foundation/handlers/create-checkout-session.write.js +33 -2
  61. package/dist/billing-foundation/handlers/process-event.write.d.ts +2 -2
  62. package/dist/billing-foundation/handlers/process-event.write.js +43 -15
  63. package/dist/billing-foundation/handlers/process-payment-event.write.d.ts +2 -1
  64. package/dist/billing-foundation/handlers/process-payment-event.write.js +6 -2
  65. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +17 -1
  66. package/dist/billing-foundation/handlers/sync-subscription.write.js +0 -1
  67. package/dist/billing-foundation/i18n.js +61 -0
  68. package/dist/billing-foundation/index.d.ts +3 -2
  69. package/dist/billing-foundation/index.js +2 -1
  70. package/dist/billing-foundation/tenant-destroy-hook.js +4 -4
  71. package/dist/billing-foundation/types.d.ts +54 -9
  72. package/dist/billing-foundation/validate-options.js +51 -10
  73. package/dist/billing-foundation/web/billing-plans-panel.js +40 -3
  74. package/dist/billing-foundation/web/cancel-contract-dialog.d.ts +7 -0
  75. package/dist/billing-foundation/web/cancel-contract-dialog.js +57 -0
  76. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +28 -0
  77. package/dist/billing-foundation/web/checkout-consent-dialog.js +38 -0
  78. package/dist/billing-foundation/webhook-handler.js +5 -2
  79. package/dist/cap-counter/with-cap-enforcement.js +7 -3
  80. package/dist/cap-overview/access.d.ts +2 -1
  81. package/dist/cap-overview/access.js +26 -5
  82. package/dist/cap-overview/feature.d.ts +4 -0
  83. package/dist/cap-overview/feature.js +8 -6
  84. package/dist/cap-overview/handlers/caps-usage.query.d.ts +1 -1
  85. package/dist/cap-overview/handlers/caps-usage.query.js +6 -4
  86. package/dist/cap-overview/handlers/tenant-caps-list.query.js +12 -7
  87. package/dist/cap-overview/index.d.ts +2 -2
  88. package/dist/cap-overview/index.js +2 -2
  89. package/dist/cap-overview/screens.d.ts +1 -1
  90. package/dist/cap-overview/screens.js +17 -16
  91. package/dist/channel-discord/discord-channel.d.ts +6 -0
  92. package/dist/channel-discord/discord-channel.js +19 -0
  93. package/dist/channel-discord/feature.d.ts +3 -0
  94. package/dist/channel-discord/feature.js +30 -0
  95. package/dist/channel-discord/index.d.ts +2 -0
  96. package/dist/channel-discord/index.js +2 -0
  97. package/dist/channel-slack/feature.d.ts +3 -0
  98. package/dist/channel-slack/feature.js +30 -0
  99. package/dist/channel-slack/index.d.ts +2 -0
  100. package/dist/channel-slack/index.js +2 -0
  101. package/dist/channel-slack/slack-channel.d.ts +5 -0
  102. package/dist/channel-slack/slack-channel.js +16 -0
  103. package/dist/channel-teams/feature.d.ts +3 -0
  104. package/dist/channel-teams/feature.js +34 -0
  105. package/dist/channel-teams/index.d.ts +2 -0
  106. package/dist/channel-teams/index.js +2 -0
  107. package/dist/channel-teams/teams-channel.d.ts +5 -0
  108. package/dist/channel-teams/teams-channel.js +38 -0
  109. package/dist/channel-telegram/feature.d.ts +3 -0
  110. package/dist/channel-telegram/feature.js +28 -0
  111. package/dist/channel-telegram/index.d.ts +2 -0
  112. package/dist/channel-telegram/index.js +2 -0
  113. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  114. package/dist/channel-telegram/telegram-channel.js +54 -0
  115. package/dist/config/handlers/set.write.js +18 -2
  116. package/dist/crypto-shredding/feature.js +3 -2
  117. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -1
  118. package/dist/crypto-shredding/handlers/forget-subject.write.js +30 -19
  119. package/dist/custom-fields/feature.js +9 -6
  120. package/dist/custom-fields/handlers/clear-custom-field.write.js +2 -1
  121. package/dist/custom-fields/handlers/define-tenant-field.write.js +2 -1
  122. package/dist/custom-fields/handlers/set-custom-field.write.js +2 -1
  123. package/dist/custom-fields/wire-user-data-rights.js +1 -1
  124. package/dist/data-retention/feature.js +1 -1
  125. package/dist/delivery/channel-context.d.ts +2 -1
  126. package/dist/delivery/channel-context.js +2 -2
  127. package/dist/delivery/chat-webhook-channel.d.ts +19 -0
  128. package/dist/delivery/chat-webhook-channel.js +48 -0
  129. package/dist/delivery/chat-webhook-sender.d.ts +21 -0
  130. package/dist/delivery/chat-webhook-sender.js +102 -0
  131. package/dist/delivery/delivery-service.js +12 -7
  132. package/dist/delivery/index.d.ts +2 -0
  133. package/dist/delivery/index.js +2 -0
  134. package/dist/delivery/jobs.js +1 -1
  135. package/dist/delivery/testing.d.ts +2 -0
  136. package/dist/delivery/types.d.ts +3 -1
  137. package/dist/delivery/types.js +1 -2
  138. package/dist/document-ingest-foundation/feature.js +3 -2
  139. package/dist/file-derivatives/handlers/public-variant-by-file-ref.query.js +1 -1
  140. package/dist/files-tenant-data/index.js +1 -1
  141. package/dist/form-draft/feature.js +1 -1
  142. package/dist/form-draft/handlers/discard.write.js +2 -1
  143. package/dist/form-draft/handlers/save.write.js +2 -1
  144. package/dist/inbound-mail-foundation/connect-routes.d.ts +7 -0
  145. package/dist/inbound-mail-foundation/connect-routes.js +13 -8
  146. package/dist/inbound-mail-foundation/feature.js +1 -1
  147. package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +2 -1
  148. package/dist/inbound-mail-foundation/index.d.ts +1 -0
  149. package/dist/inbound-mail-foundation/index.js +1 -0
  150. package/dist/inbound-mail-foundation/oauth-access-token.d.ts +28 -0
  151. package/dist/inbound-mail-foundation/oauth-access-token.js +131 -0
  152. package/dist/inbound-mail-foundation/tenant-destroy-hook.js +1 -1
  153. package/dist/inbound-mail-foundation/types.d.ts +18 -1
  154. package/dist/inbound-mail-foundation/watch-supervisor.d.ts +5 -0
  155. package/dist/inbound-mail-foundation/watch-supervisor.js +42 -14
  156. package/dist/managed-pages/handlers/set.write.js +2 -1
  157. package/dist/notes-history/executor.d.ts +2 -0
  158. package/dist/notes-history/executor.js +9 -1
  159. package/dist/notes-history/feature.d.ts +2 -3
  160. package/dist/notes-history/feature.js +2 -1
  161. package/dist/notes-history/handlers/add-note.write.d.ts +2 -1
  162. package/dist/notes-history/handlers/add-note.write.js +21 -4
  163. package/dist/notes-history-user-data/hooks.js +26 -8
  164. package/dist/renderer-simple/feature.d.ts +8 -2
  165. package/dist/renderer-simple/feature.js +8 -5
  166. package/dist/renderer-simple/index.d.ts +2 -2
  167. package/dist/renderer-simple/index.js +1 -1
  168. package/dist/renderer-simple/simple-renderer.d.ts +15 -0
  169. package/dist/renderer-simple/simple-renderer.js +74 -27
  170. package/dist/secrets/feature.js +1 -1
  171. package/dist/sessions/feature.js +1 -1
  172. package/dist/sessions/handlers/revoke-all-for-user.write.js +2 -1
  173. package/dist/subscription-mollie/plugin-methods.js +1 -0
  174. package/dist/subscription-mollie/verify-webhook.js +5 -3
  175. package/dist/subscription-stripe/plugin-methods.d.ts +3 -1
  176. package/dist/subscription-stripe/plugin-methods.js +78 -13
  177. package/dist/subscription-stripe/verify-webhook.js +7 -3
  178. package/dist/tags/executor.d.ts +2 -0
  179. package/dist/tags/executor.js +9 -1
  180. package/dist/tags/feature.d.ts +4 -3
  181. package/dist/tags/feature.js +4 -2
  182. package/dist/tags/handlers/assign-tag.write.d.ts +2 -1
  183. package/dist/tags/handlers/assign-tag.write.js +4 -4
  184. package/dist/tags/handlers/remove-tag.write.d.ts +2 -1
  185. package/dist/tags/handlers/remove-tag.write.js +3 -3
  186. package/dist/tenant-handover/handlers/claim.write.js +2 -2
  187. package/dist/tenant-lifecycle/feature.js +1 -1
  188. package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +4 -1
  189. package/dist/tenant-lifecycle/handlers/request-destruction.write.js +2 -1
  190. package/dist/tier-engine/feature.js +1 -1
  191. package/dist/tier-engine/handlers/get-tenant-tier.query.js +2 -1
  192. package/dist/tier-engine/handlers/set-tenant-tier.write.js +2 -1
  193. package/dist/user/handlers/detail.query.js +1 -0
  194. package/dist/user/handlers/list.query.js +1 -0
  195. package/dist/user-data-rights/feature.js +2 -2
  196. package/dist/user-data-rights/handlers/cancel-deletion.write.js +2 -1
  197. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +1 -1
  198. package/dist/user-data-rights/handlers/download-attempt-list.query.js +1 -0
  199. package/dist/user-data-rights/handlers/download-by-job.query.js +2 -1
  200. package/dist/user-data-rights/handlers/download-by-token.query.js +2 -1
  201. package/dist/user-data-rights/handlers/export-status.query.js +2 -1
  202. package/dist/user-data-rights/handlers/lift-restriction.write.js +3 -4
  203. package/dist/user-data-rights/handlers/my-audit-log.query.js +2 -1
  204. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +2 -1
  205. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -1
  206. package/dist/user-data-rights/handlers/request-export.write.js +2 -1
  207. package/dist/user-data-rights/handlers/restrict-account.write.js +2 -2
  208. package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +2 -1
  209. package/dist/user-data-rights/lib/update-user-lifecycle.js +1 -1
  210. package/dist/user-data-rights/run-forget-cleanup.js +30 -15
  211. package/dist/workflow-runner/event-subscriber.js +6 -2
  212. package/dist/workflow-runner/event-trigger.js +5 -0
  213. package/dist/workflow-runner/handlers/resume-run.write.js +147 -59
  214. package/dist/workflow-runner/pending-projection.js +1 -0
  215. package/dist/workflow-runner/runner.d.ts +7 -1
  216. package/dist/workflow-runner/runner.js +1 -1
  217. package/dist/workflow-runner/tables.js +8 -1
  218. package/package.json +41 -9
  219. package/src/agent-tools/changes.json +14 -0
  220. package/src/auth-email-password/changes.json +7 -0
  221. package/src/billing-foundation/changes.json +83 -0
  222. package/src/cap-counter/changes.json +7 -0
  223. package/src/cap-overview/changes.json +14 -0
  224. package/src/config/changes.json +7 -0
  225. package/src/crypto-shredding/changes.json +13 -0
  226. package/src/custom-fields/changes.json +7 -0
  227. package/src/delivery/changes.json +14 -0
  228. package/src/inbound-mail-foundation/changes.json +7 -0
  229. package/src/notes-history/changes.json +7 -0
  230. package/src/renderer-simple/changes.json +9 -1
  231. package/src/subscription-mollie/changes.json +13 -0
  232. package/src/subscription-stripe/changes.json +13 -0
  233. package/src/tags/changes.json +7 -0
  234. package/src/user-data-rights/changes.json +6 -0
  235. package/src/workflow-runner/changes.json +7 -0
@@ -1,8 +1,7 @@
1
- import { createHash } from "node:crypto";
2
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
3
2
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
4
- import { configuredPiiSubjectKms, subjectIdSchema, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
5
- import { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "@cosmicdrift/kumiko-framework/db";
3
+ import { computeBlindIndex, configuredBlindIndexKey, configuredPiiSubjectKms, subjectIdSchema, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
4
+ import { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "@cosmicdrift/kumiko-framework/db";
6
5
  import { defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
7
6
  import { AccessDeniedError, InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
8
7
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
@@ -26,7 +25,7 @@ export const subjectForgottenSchema = z.object({
26
25
  forgottenBy: z.string().min(1),
27
26
  });
28
27
  export const subjectForgetDeniedSchema = z.object({
29
- subjectKeyDigest: z.string().min(1),
28
+ subjectKeyDigest: z.string().min(1).optional(),
30
29
  subjectKind: z.enum(["user", "tenant", "record"]),
31
30
  reason: z.string().min(10),
32
31
  forgottenBy: z.string().min(1),
@@ -78,13 +77,20 @@ async function resolveTenantScopeDenial(db, features, user, raw) {
78
77
  if (!features.has("tenant"))
79
78
  return undefined;
80
79
  const entity = findRegisteredEntity(features, raw.entity);
81
- if (!entity) {
82
- return writeFailure(new AccessDeniedError({ details: { reason: RECORD_ENTITY_NOT_REGISTERED } }));
80
+ const rowInTenant = entity !== undefined &&
81
+ (await recordRowExistsInTenant(db, features, raw.entity, raw.id, user.tenantId));
82
+ if (rowInTenant)
83
+ return undefined;
84
+ // A deleted row or a custom aggregate without an entity has no projection
85
+ // row to check; its event stream still proves which tenant owns it.
86
+ if (await recordEventsOwnedExclusivelyByTenant(db, raw.entity, raw.id, user.tenantId)) {
87
+ return undefined;
83
88
  }
84
- const ownedInTenant = await recordRowExistsInTenant(db, features, raw.entity, raw.id, user.tenantId);
85
- return ownedInTenant
86
- ? undefined
87
- : writeFailure(new AccessDeniedError({ details: { reason: TARGET_RECORD_NOT_ADMIN_TENANT } }));
89
+ return writeFailure(new AccessDeniedError({
90
+ details: {
91
+ reason: entity ? TARGET_RECORD_NOT_ADMIN_TENANT : RECORD_ENTITY_NOT_REGISTERED,
92
+ },
93
+ }));
88
94
  }
89
95
  // Without the tenant feature there's no membership table to check against —
90
96
  // no scoping concept exists to enforce (single/no-tenant apps only;
@@ -160,15 +166,19 @@ async function appendDenialAuditEvent(ctx, event, subjectKey, subjectKind, denia
160
166
  message: `[crypto-shredding] event "${SUBJECT_FORGET_DENIED_EVENT_NAME}" is not registered.`,
161
167
  }));
162
168
  }
169
+ const blindIndexKey = configuredBlindIndexKey();
163
170
  const payload = subjectForgetDeniedSchema.parse({
164
171
  // The denial lands in the REQUESTING actor's own tenant-scoped stream —
165
172
  // it must never materialise the foreign subject's identifiers there. A
166
173
  // plaintext subjectKey/aggregateId would survive as a permanent record
167
174
  // for the prober and would still be present when the owning tenant
168
175
  // later runs its own (legitimate) forget-subject for that subject. The
169
- // digest still lets an operator correlate repeated probes of the same
170
- // subject without exposing it (fw#2452).
171
- subjectKeyDigest: createHash("sha256").update(subjectKey, "utf8").digest("base64url"),
176
+ // digest is keyed (a bare hash of a guessable id could be recomputed from
177
+ // a candidate id) and lets an operator correlate repeated probes of the
178
+ // same subject; without a blind-index key no digest is stored at all.
179
+ ...(blindIndexKey !== undefined && {
180
+ subjectKeyDigest: computeBlindIndex(blindIndexKey, subjectKey),
181
+ }),
172
182
  subjectKind,
173
183
  reason: event.payload.reason,
174
184
  forgottenBy: event.user.id,
@@ -216,6 +226,7 @@ export const forgetSubjectWrite = defineWriteHandler({
216
226
  // not be able to reach it at all.
217
227
  agent: { expose: false, risk: "high" },
218
228
  escapeHatch: {
229
+ grants: ["unsafeRaw"],
219
230
  reason: "denial audit append names the prober's own tenant stream on the outside-transaction db; " +
220
231
  "the tenant-scope and retention checks run against the subject's tenant, not necessarily the caller's; " +
221
232
  "the blind-index sweep and search purge address the subject across tenants; the user " +
@@ -237,17 +248,17 @@ export const forgetSubjectWrite = defineWriteHandler({
237
248
  ? { kind: "tenant", tenantId: raw.tenantId }
238
249
  : { kind: "record", entity: raw.entity, id: raw.id };
239
250
  const subjectKey = subjectIdToKey(subject);
240
- const tenantScopeDenial = await resolveTenantScopeDenial(ctx.db.unsafeRaw("tenant-scope check runs against the subject's tenant, not necessarily the caller's"), ctx.registry.features, event.user, raw);
251
+ const tenantScopeDenial = await resolveTenantScopeDenial(ctx.db.unsafeRaw(), ctx.registry.features, event.user, raw);
241
252
  if (tenantScopeDenial) {
242
- const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, denialReasonOf(tenantScopeDenial), ctx.dbOutsideTransaction?.unsafeRaw("denial audit append: names the prober's own tenant stream on the outside-transaction db"));
253
+ const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, denialReasonOf(tenantScopeDenial), ctx.dbOutsideTransaction?.unsafeRaw());
243
254
  return auditFailure ?? tenantScopeDenial;
244
255
  }
245
256
  // Runs after the tenant gate but before any key is touched: a retention
246
257
  // check ahead of the tenant gate would leak a foreign entity's retention
247
258
  // posture to a cross-tenant prober.
248
- const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw("retention check reads the record row's owning tenant, not the caller's"));
259
+ const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw());
249
260
  if (retentionDenial) {
250
- const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, TARGET_RECORD_RETENTION_BLOCK_DELETE, ctx.dbOutsideTransaction?.unsafeRaw("denial audit append: names the prober's own tenant stream on the outside-transaction db"));
261
+ const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, TARGET_RECORD_RETENTION_BLOCK_DELETE, ctx.dbOutsideTransaction?.unsafeRaw());
251
262
  return auditFailure ?? retentionDenial;
252
263
  }
253
264
  // Erase BEFORE the audit append: if the append throws, the key is gone
@@ -261,7 +272,7 @@ export const forgetSubjectWrite = defineWriteHandler({
261
272
  });
262
273
  // Blind-index sweep (#818): nulls bidx columns now so the deterministic
263
274
  // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
264
- const crossTenantSubjectRunner = ctx.db.unsafeRaw("blind-index sweep and search purge address the subject across tenants");
275
+ const crossTenantSubjectRunner = ctx.db.unsafeRaw();
265
276
  await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
266
277
  // Derived search index still holds plaintext (#1610) — purge next to the
267
278
  // blind-index sweep. No adapter → no-op (apps without search).
@@ -289,7 +300,7 @@ export const forgetSubjectWrite = defineWriteHandler({
289
300
  // update is best-effort for real users.
290
301
  if (raw.kind === "user" && ctx.registry.features.has("user")) {
291
302
  try {
292
- const userLifecycleRunner = ctx.db.unsafeRaw("user lifecycle update and PAT revoke run on the SYSTEM user stream");
303
+ const userLifecycleRunner = ctx.db.unsafeRaw();
293
304
  await updateUserLifecycle(userLifecycleRunner, raw.userId, { status: USER_STATUS.Deleted });
294
305
  if (ctx.registry.features.has("personal-access-tokens")) {
295
306
  await revokeAllPatTokensForUser(userLifecycleRunner, raw.userId);
@@ -136,12 +136,15 @@ export function resolveFieldDefinitionListRoles(opts) {
136
136
  if (opts.valueWriteRoles === undefined && opts.fieldDefinitionWriteRoles === undefined) {
137
137
  return DEFAULT_FIELD_DEFINITION_LIST_ROLES;
138
138
  }
139
- // Write-roles override replaces the default role vocab — don't keep unioning
140
- // TenantAdmin onto list when the app opted into its own write roles.
141
- if (opts.fieldDefinitionWriteRoles !== undefined) {
142
- return [...new Set([...(opts.valueWriteRoles ?? []), ...opts.fieldDefinitionWriteRoles])];
143
- }
144
- return [...new Set([...(opts.valueWriteRoles ?? []), ...DEFAULT_FIELD_DEFINITION_LIST_ROLES])];
139
+ // The default stays in the union: with valueWriteRoles unset, TenantAdmin still
140
+ // saves values and must be able to load the FormSection.
141
+ return [
142
+ ...new Set([
143
+ ...(opts.valueWriteRoles ?? []),
144
+ ...(opts.fieldDefinitionWriteRoles ?? []),
145
+ ...DEFAULT_FIELD_DEFINITION_LIST_ROLES,
146
+ ]),
147
+ ];
145
148
  }
146
149
  // Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
147
150
  // host-apps) nutzen weiterhin `createCustomFieldsFeature()`. Returnt den
@@ -26,11 +26,12 @@ export const clearCustomFieldHandler = {
26
26
  access: { roles: DEFAULT_VALUE_WRITE_ROLES },
27
27
  description: "Removes the stored value of one custom field from a single host entity row after re-checking that field's per-field write roles; use it to blank a field the user emptied instead of writing a null value.",
28
28
  escapeHatch: {
29
+ grants: ["unsafeRaw"],
29
30
  reason: CLEAR_CUSTOM_FIELD_REASON,
30
31
  },
31
32
  handler: async (event, ctx) => {
32
33
  const payload = event.payload; // @cast-boundary engine-payload
33
- const accessCheck = await checkFieldAccessForWrite(ctx.db.unsafeRaw(CLEAR_CUSTOM_FIELD_REASON), event.user.tenantId, payload.entityName, payload.fieldKey, event.user.roles);
34
+ const accessCheck = await checkFieldAccessForWrite(ctx.db.unsafeRaw(), event.user.tenantId, payload.entityName, payload.fieldKey, event.user.roles);
34
35
  if (!accessCheck.ok) {
35
36
  if (accessCheck.reason === "field_definition_not_found") {
36
37
  return failNotFound("fieldDefinition", payload.fieldKey);
@@ -15,6 +15,7 @@ export function createDefineTenantFieldHandler(opts = {}) {
15
15
  access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
16
16
  description: "Creates a custom-field definition owned by the caller's own tenant on the named entity, rejecting the write once the tenant's definition quota is reached; use it when one tenant needs an extra field the other tenants must not see.",
17
17
  escapeHatch: {
18
+ grants: ["unsafeRaw"],
18
19
  reason: DEFINE_TENANT_FIELD_REASON,
19
20
  },
20
21
  handler: async (event, ctx) => {
@@ -25,7 +26,7 @@ export function createDefineTenantFieldHandler(opts = {}) {
25
26
  throw new Error("define-tenant-field: tenantId is SYSTEM_TENANT_ID — use define-system-field for system-scope definitions");
26
27
  }
27
28
  if (limit !== undefined) {
28
- const current = await countTenantFieldDefinitions(ctx.db.unsafeRaw(DEFINE_TENANT_FIELD_REASON), tenantId);
29
+ const current = await countTenantFieldDefinitions(ctx.db.unsafeRaw(), tenantId);
29
30
  if (current >= limit) {
30
31
  return failUnprocessable("cap_exceeded", {
31
32
  capName: "customFields.fieldDefinition.count",
@@ -49,11 +49,12 @@ export const setCustomFieldHandler = {
49
49
  access: { roles: DEFAULT_VALUE_WRITE_ROLES },
50
50
  description: "Stores one custom-field value on a single host entity row after validating it against the field definition's declared type and per-field write roles; use it to save what a user entered into a custom field.",
51
51
  escapeHatch: {
52
+ grants: ["unsafeRaw"],
52
53
  reason: SET_CUSTOM_FIELD_REASON,
53
54
  },
54
55
  handler: async (event, ctx) => {
55
56
  const payload = event.payload; // @cast-boundary engine-payload
56
- const loaded = await loadFieldDefinition(ctx.db.unsafeRaw(SET_CUSTOM_FIELD_REASON), event.user.tenantId, payload.entityName, payload.fieldKey);
57
+ const loaded = await loadFieldDefinition(ctx.db.unsafeRaw(), event.user.tenantId, payload.entityName, payload.fieldKey);
57
58
  if (!loaded.found) {
58
59
  return failNotFound("fieldDefinition", payload.fieldKey);
59
60
  }
@@ -46,7 +46,7 @@ export function wireCustomFieldsUserDataRightsFor(r, opts) {
46
46
  const tableName = extractTableName(opts.entityTable, "custom-fields/wire-user-data-rights");
47
47
  // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
48
48
  r.useExtension(EXT_USER_DATA, opts.entityName, {
49
- export: async (ctx) => exportCustomFields(ctx.db.unsafeRaw(CUSTOM_FIELDS_EXPORT_REASON), ctx, tableName, opts),
49
+ export: async (ctx) => exportCustomFields(ctx.db.unsafeRaw(), ctx, tableName, opts),
50
50
  escapeHatch: { reason: CUSTOM_FIELDS_EXPORT_REASON },
51
51
  });
52
52
  }
@@ -64,7 +64,7 @@ export function createDataRetentionFeature() {
64
64
  return;
65
65
  }
66
66
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
67
- const cleanupDb = ctx.db.unsafeRaw("retention cleanup runs raw deletes and preset lookups; every statement filters by this run's tenantId"); // @cast-boundary db-operator — jobs never run inside a DbTx
67
+ const cleanupDb = ctx.db.unsafeRaw(); // @cast-boundary db-operator — jobs never run inside a DbTx
68
68
  const tenantPreset = await resolveTenantRetentionPreset({
69
69
  db: cleanupDb,
70
70
  registry: ctx.registry,
@@ -1,5 +1,6 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
4
5
  import type { ChannelContext } from "./types.js";
5
- export declare function buildChannelContext(db: DbConnection, registry: Registry, sseBroker: SseBroker | undefined, tenantId: TenantId): ChannelContext;
6
+ export declare function buildChannelContext(db: DbConnection, registry: Registry, sseBroker: SseBroker | undefined, tenantId: TenantId, secrets?: SecretsContext): ChannelContext;
@@ -3,6 +3,6 @@ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  // Shared by the synchronous delivery-service path and the async job handlers
4
4
  // (which pass sseBroker=undefined — only the inline inApp channel uses SSE,
5
5
  // and it never runs through a job).
6
- export function buildChannelContext(db, registry, sseBroker, tenantId) {
7
- return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId };
6
+ export function buildChannelContext(db, registry, sseBroker, tenantId, secrets) {
7
+ return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId, secrets };
8
8
  }
@@ -0,0 +1,19 @@
1
+ import { type ChatSendResult } from "./chat-webhook-sender.js";
2
+ import type { ChannelMessage, ChannelResult, DeliveryChannel } from "./types.js";
3
+ export type ChatWebhookChannelOptions = {
4
+ readonly allowedHosts?: readonly string[];
5
+ readonly requireHttps?: boolean;
6
+ readonly timeoutMs?: number;
7
+ };
8
+ export declare function toChannelResult(address: string, result: ChatSendResult): ChannelResult;
9
+ export declare function chatMessageText(message: ChannelMessage): string;
10
+ export type ChatWebhookChannelSpec = {
11
+ readonly name: string;
12
+ readonly featureName: string;
13
+ readonly defaultAllowedHosts: readonly string[];
14
+ readonly requiredPathPrefix?: string;
15
+ readonly keyFor: (connection: string) => string;
16
+ readonly buildBody: (message: ChannelMessage) => unknown;
17
+ readonly options: ChatWebhookChannelOptions;
18
+ };
19
+ export declare function createChatWebhookChannel(spec: ChatWebhookChannelSpec): DeliveryChannel;
@@ -0,0 +1,48 @@
1
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
2
+ import { chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, } from "./chat-webhook-sender.js";
3
+ export function toChannelResult(address, result) {
4
+ return result.ok
5
+ ? { status: "sent", address }
6
+ : { status: "failed", error: result.code, address };
7
+ }
8
+ export function chatMessageText(message) {
9
+ return message.body ? `${message.title}\n${message.body}` : message.title;
10
+ }
11
+ // Slack/Discord/Teams: the route address is a tenant-chosen connection name, the
12
+ // webhook URL lives in the secrets store under that name. Route-only (no resolve).
13
+ export function createChatWebhookChannel(spec) {
14
+ const { options } = spec;
15
+ return {
16
+ name: spec.name,
17
+ mode: "queued",
18
+ async send(address, message, ctx) {
19
+ if (!chatConnectionNameSchema.safeParse(address).success) {
20
+ return toChannelResult(address, { ok: false, code: "invalid_address" });
21
+ }
22
+ let url;
23
+ try {
24
+ const revealed = await ctx.secrets?.get(ctx.tenantId, spec.keyFor(address), {
25
+ userId: SYSTEM_USER_ID,
26
+ handlerName: `${spec.featureName}:send`,
27
+ });
28
+ url = revealed?.reveal();
29
+ }
30
+ catch {
31
+ url = undefined;
32
+ }
33
+ if (!url)
34
+ return toChannelResult(address, { ok: false, code: "missing_credentials" });
35
+ const result = await postChatWebhook({
36
+ url,
37
+ allowedHosts: options.allowedHosts ?? spec.defaultAllowedHosts,
38
+ requireHttps: options.requireHttps ?? true,
39
+ ...(spec.requiredPathPrefix !== undefined && {
40
+ requiredPathPrefix: spec.requiredPathPrefix,
41
+ }),
42
+ timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
43
+ body: spec.buildBody(message),
44
+ });
45
+ return toChannelResult(address, result);
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,21 @@
1
+ import * as z from "zod";
2
+ export type ChatSendFailureCode = `http_${number}` | "timeout" | "network_error" | "redirect_blocked" | "host_not_allowed" | "missing_credentials" | "invalid_address";
3
+ export type ChatSendResult = {
4
+ readonly ok: true;
5
+ } | {
6
+ readonly ok: false;
7
+ readonly code: ChatSendFailureCode;
8
+ };
9
+ export declare const DEFAULT_CHAT_TIMEOUT_MS = 10000;
10
+ export declare const chatConnectionNameSchema: z.ZodString;
11
+ export type ChatWebhookRequest = {
12
+ readonly url: string;
13
+ readonly allowedHosts: readonly string[];
14
+ readonly requireHttps: boolean;
15
+ readonly requiredPathPrefix?: string;
16
+ readonly timeoutMs: number;
17
+ readonly body: unknown;
18
+ readonly send?: (url: string, init: RequestInit) => Promise<Response>;
19
+ };
20
+ export declare function postChatWebhook(request: ChatWebhookRequest): Promise<ChatSendResult>;
21
+ export declare function truncateChars(text: string, maxChars: number): string;
@@ -0,0 +1,102 @@
1
+ import { BlockedHostError, egress } from "@cosmicdrift/kumiko-framework/http";
2
+ import * as z from "zod";
3
+ export const DEFAULT_CHAT_TIMEOUT_MS = 10_000;
4
+ // Tenant-chosen connection name (the route address for webhook channels). Also
5
+ // the suffix the secrets write gate accepts under the channel's namespace, so
6
+ // the two cannot drift. Validated before it is used to build a secret key.
7
+ export const chatConnectionNameSchema = z
8
+ .string()
9
+ .min(1)
10
+ .max(64)
11
+ .regex(/^[a-z0-9][a-z0-9-]*$/);
12
+ // https targets are provider endpoints on public hosts: egress "external" adds
13
+ // the private/reserved-range denial and DNS pinning on top of our allowlist. The
14
+ // cleartext opt-out (requireHttps: false) exists for operator-trusted local
15
+ // endpoints, which "external" would refuse; the allowlist already pins the host.
16
+ function policyFor(request, target) {
17
+ return request.requireHttps
18
+ ? { kind: "external" }
19
+ : { kind: "internal", allowHosts: [target.hostname] };
20
+ }
21
+ function hostMatches(hostname, allowedHost) {
22
+ const allowed = allowedHost.toLowerCase();
23
+ if (allowed.startsWith(".")) {
24
+ return hostname.length > allowed.length && hostname.endsWith(allowed);
25
+ }
26
+ return hostname === allowed;
27
+ }
28
+ // Allowlist runs on the parsed URL, never on the raw string: userinfo
29
+ // (`https://hooks.slack.com@evil.com`) and look-alike suffixes must not pass.
30
+ function checkTarget(request) {
31
+ let parsed;
32
+ try {
33
+ parsed = new URL(request.url);
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ const protocolAllowed = parsed.protocol === "https:" || (!request.requireHttps && parsed.protocol === "http:");
39
+ if (!protocolAllowed)
40
+ return undefined;
41
+ // egress() has no port rule; a provider webhook over https never needs a custom port.
42
+ if (request.requireHttps && parsed.port !== "")
43
+ return undefined;
44
+ if (parsed.username !== "" || parsed.password !== "")
45
+ return undefined;
46
+ const hostname = parsed.hostname.toLowerCase();
47
+ if (!request.allowedHosts.some((allowed) => hostMatches(hostname, allowed)))
48
+ return undefined;
49
+ if (request.requiredPathPrefix !== undefined &&
50
+ !parsed.pathname.startsWith(request.requiredPathPrefix)) {
51
+ return undefined;
52
+ }
53
+ return parsed;
54
+ }
55
+ function isTimeout(err) {
56
+ return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
57
+ }
58
+ // egress(internal) follows same-host redirects itself and throws on cross-host
59
+ // ones; both are the "redirect" outcome here. Matched on the stable message
60
+ // prefix only — the message is discarded, it can carry the Location header.
61
+ function isEgressRedirectRefusal(err) {
62
+ return err instanceof Error && err.message.startsWith("egress(internal): redirect");
63
+ }
64
+ function classifyFailure(err) {
65
+ if (err instanceof BlockedHostError)
66
+ return "host_not_allowed";
67
+ if (isEgressRedirectRefusal(err))
68
+ return "redirect_blocked";
69
+ return isTimeout(err) ? "timeout" : "network_error";
70
+ }
71
+ // Never throws and never surfaces the URL: egress/Bun errors can carry it, so
72
+ // every failure is reduced to a code here.
73
+ export async function postChatWebhook(request) {
74
+ const target = checkTarget(request);
75
+ if (!target)
76
+ return { ok: false, code: "host_not_allowed" };
77
+ let response;
78
+ try {
79
+ const send = request.send ?? egress(policyFor(request, target));
80
+ response = await send(target.toString(), {
81
+ method: "POST",
82
+ headers: { "content-type": "application/json" },
83
+ body: JSON.stringify(request.body),
84
+ signal: AbortSignal.timeout(request.timeoutMs),
85
+ });
86
+ }
87
+ catch (err) {
88
+ return { ok: false, code: classifyFailure(err) };
89
+ }
90
+ // Free the connection; the body is never read or stored.
91
+ await response.body?.cancel().catch(() => undefined);
92
+ const { status } = response;
93
+ if (status >= 300 && status < 400)
94
+ return { ok: false, code: "redirect_blocked" };
95
+ if (status >= 200 && status < 300)
96
+ return { ok: true };
97
+ return { ok: false, code: `http_${status}` };
98
+ }
99
+ export function truncateChars(text, maxChars) {
100
+ const chars = Array.from(text);
101
+ return chars.length <= maxChars ? text : chars.slice(0, maxChars).join("");
102
+ }
@@ -88,16 +88,18 @@ export function createDeliveryService(options) {
88
88
  sink: escapeHatchAuditSink,
89
89
  log,
90
90
  });
91
- // Mirrors buildHandlerContext (pipeline/dispatch-shared.ts): a non-systemScope
92
- // tenantUserIdsQuery must get the same tenant-filtered db/no-systemDb context
93
- // the real dispatcher would give it — handing it "system" mode + a systemDb
94
- // view regardless would grant cross-tenant reach the dispatcher never would.
91
+ // ctx.db/dbOutsideTransaction are tenant-filtered even for a systemScope handler:
92
+ // the dispatcher closes them off entirely there, so "system" mode would hand this
93
+ // path a cross-tenant read the dispatcher never allows. Cross-tenant reach only
94
+ // exists through the grant-carrying systemDb view below.
95
95
  const isSystem = registry.isHandlerSystemScoped(tenantUserIdsQuery);
96
- const tenantDb = createTenantDb(db, tenantId, isSystem ? "system" : "tenant", undefined, undefined, undefined, { report });
96
+ const tenantDb = createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, {
97
+ report,
98
+ });
97
99
  // The dispatcher grants a systemScope handler's ctx.systemDb.unsafeRaw() ungated —
98
100
  // the grant comes from the handler's own r.systemScope() registration, not from this
99
- // caller. ctx.db/dbOutsideTransaction stay on the grant-less tenantDb above; only the
100
- // systemDb view's own source TenantDb carries the grant, so it can't leak onto ctx.db.
101
+ // caller. Only the systemDb view's own source TenantDb carries the grant, so it
102
+ // can't leak onto ctx.db.
101
103
  const systemDbSourceTenantDb = isSystem
102
104
  ? createTenantDb(db, tenantId, "system", undefined, undefined, undefined, {
103
105
  report,
@@ -227,6 +229,9 @@ export function createDeliveryService(options) {
227
229
  async function deliverToUser(userId, notificationType, data, tenantId, priority) {
228
230
  const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
229
231
  for (const channel of channels) {
232
+ // Route-only channel (no per-user address): not a user-notification target.
233
+ if (!channel.resolve)
234
+ continue;
230
235
  const message = buildMessage(notificationType, data, channel.name);
231
236
  // Kill switch: tenant admin disabled this channel entirely
232
237
  if (isChannelKilled) {
@@ -1,4 +1,6 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
+ export { type ChatWebhookChannelOptions, chatMessageText, createChatWebhookChannel, toChannelResult, } from "./chat-webhook-channel.js";
3
+ export { type ChatSendFailureCode, type ChatSendResult, type ChatWebhookRequest, chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
2
4
  export type { DeliveryStatusValue } from "./constants.js";
3
5
  export { DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
4
6
  export { collectChannels, createDeliveryService, type DeliveryServiceOptions, type KillSwitchResolver, type RateLimitConfig, } from "./delivery-service.js";
@@ -1,4 +1,6 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
+ export { chatMessageText, createChatWebhookChannel, toChannelResult, } from "./chat-webhook-channel.js";
3
+ export { chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
2
4
  export { DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
3
5
  export { collectChannels, createDeliveryService, } from "./delivery-service.js";
4
6
  export { createDeliveryFeature } from "./feature.js";
@@ -103,7 +103,7 @@ export const deliverySendJob = async (payload, ctx) => {
103
103
  const tenantId = p.tenantId; // @cast-boundary engine-payload — stream tenant
104
104
  const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
105
105
  const channel = resolveChannel(registry, p.channelName);
106
- const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
106
+ const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets);
107
107
  try {
108
108
  const result = await channel.send(p.address, toMessage(p), channelCtx, p.rendered);
109
109
  await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, result.status, result.error ?? null, result.address ?? p.address));
@@ -1,6 +1,7 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { EscapeHatchAuditSink, Registry } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
4
5
  import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
5
6
  import type { Redis } from "ioredis";
6
7
  import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js";
@@ -11,6 +12,7 @@ export type CreateDeliveryTestContextOptions = {
11
12
  readonly isChannelKilled?: KillSwitchResolver;
12
13
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
13
14
  readonly log?: Logger;
15
+ readonly jobRunner?: JobRunner;
14
16
  };
15
17
  /**
16
18
  * Helper for setupTestStack: creates a DeliveryService + _notifyFactory for the test context.
@@ -1,12 +1,14 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
4
5
  import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
5
6
  export type ChannelContext = {
6
7
  readonly db: TenantDb;
7
8
  readonly registry: Registry;
8
9
  readonly sseBroker: SseBroker | undefined;
9
10
  readonly tenantId: TenantId;
11
+ readonly secrets?: SecretsContext | undefined;
10
12
  };
11
13
  export type ChannelMessage = {
12
14
  readonly notificationType: string;
@@ -28,7 +30,7 @@ export type DeliveryChannelMode = (typeof DELIVERY_CHANNEL_MODES)[number];
28
30
  export type DeliveryChannel = {
29
31
  readonly name: string;
30
32
  readonly mode: DeliveryChannelMode;
31
- resolve(userId: string, ctx: ChannelContext): Promise<string | null>;
33
+ resolve?(userId: string, ctx: ChannelContext): Promise<string | null>;
32
34
  render?(message: ChannelMessage, ctx: ChannelContext): Promise<RenderedMessage>;
33
35
  send(address: string, message: ChannelMessage, ctx: ChannelContext, rendered?: RenderedMessage): Promise<ChannelResult>;
34
36
  };
@@ -10,8 +10,7 @@ export function isDeliveryChannelPlugin(o) {
10
10
  "mode" in o &&
11
11
  DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
12
12
  (!("render" in o) || o.render === undefined || typeof o.render === "function") &&
13
- "resolve" in o &&
14
- typeof o.resolve === "function" &&
13
+ (!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
15
14
  "send" in o &&
16
15
  typeof o.send === "function");
17
16
  }
@@ -114,8 +114,9 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
114
114
  write: access.roles("TenantAdmin", "SystemAdmin"),
115
115
  read: access.roles("TenantAdmin", "SystemAdmin", "User"),
116
116
  }));
117
- // "fileName" can carry a real person's name; the payload has no user-subject
118
- // field to encrypt it under, so piiFields stays "none" for now.
117
+ // "fileName" can carry a real person's name, but it reaches both events already as the
118
+ // fileRef's own self-subject ciphertext (copied from fileRef.created/restored), which
119
+ // append() passes through; shredding the fileRef's key shreds it here too.
119
120
  r.defineEvent(DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, documentIngestRequestedPayloadSchema, {
120
121
  piiFields: "none",
121
122
  version: 2,
@@ -16,7 +16,7 @@ export const publicVariantByFileRefQuery = defineQueryHandler({
16
16
  reason: BY_FILE_REF_ESCAPE_HATCH_REASON,
17
17
  },
18
18
  handler: async (query, ctx) => {
19
- const db = ctx.db.unsafeRaw(BY_FILE_REF_ESCAPE_HATCH_REASON);
19
+ const db = ctx.db.unsafeRaw();
20
20
  const row = await fetchOne(db, fileRefsTable, {
21
21
  id: query.payload.fileRefId,
22
22
  isDeleted: false,
@@ -34,7 +34,7 @@ export function createFilesTenantDataFeature() {
34
34
  escapeHatch: {
35
35
  reason: SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON,
36
36
  },
37
- handler: (payload, ctx) => sweepOrphanedDerivativesJob(payload, ctx, ctx.db.unsafeRaw(SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON)),
37
+ handler: (payload, ctx) => sweepOrphanedDerivativesJob(payload, ctx, ctx.db.unsafeRaw()),
38
38
  });
39
39
  });
40
40
  }
@@ -38,7 +38,7 @@ function registerFormDraft(r) {
38
38
  trigger: { cron: "0 3 * * *" },
39
39
  concurrency: "skip",
40
40
  escapeHatch: { reason: CLEANUP_ESCAPE_HATCH_REASON },
41
- handler: (payload, ctx) => cleanupDraftsJob(payload, ctx, ctx.db.unsafeRaw(CLEANUP_ESCAPE_HATCH_REASON)),
41
+ handler: (payload, ctx) => cleanupDraftsJob(payload, ctx, ctx.db.unsafeRaw()),
42
42
  });
43
43
  }
44
44
  export const formDraftFeature = defineFeature(FORM_DRAFT_FEATURE_NAME, registerFormDraft);
@@ -29,6 +29,7 @@ export const discardDraftWrite = defineWriteHandler({
29
29
  description: "Deletes the calling user's draft for one draftKey and, when releaseFiles is set, hard-deletes the file references that draft alone uploaded; use it after a successful submit or when the user abandons the form.",
30
30
  agent: { risk: "high" },
31
31
  escapeHatch: {
32
+ grants: ["unsafeRaw"],
32
33
  reason: DISCARD_DRAFT_FILE_REFS_REASON,
33
34
  },
34
35
  handler: async (event, ctx) => {
@@ -58,7 +59,7 @@ export const discardDraftWrite = defineWriteHandler({
58
59
  // lazily on the first step-change — a file uploaded before that exists
59
60
  // predates the draft row, so the insertedAt filter must not apply.
60
61
  const isCreateMode = event.payload.draftKey.includes(":new:");
61
- const ownedRefs = await filterOwnedFileRefs(ctx.db.unsafeRaw(DISCARD_DRAFT_FILE_REFS_REASON), event.user.tenantId, ownerId, candidateKeys, existing.insertedAt, isCreateMode);
62
+ const ownedRefs = await filterOwnedFileRefs(ctx.db.unsafeRaw(), event.user.tenantId, ownerId, candidateKeys, existing.insertedAt, isCreateMode);
62
63
  // Fail-closed erase of file_refs rows FIRST (outside the best-effort
63
64
  // storage-hygiene loop). releaseDraftFileRefs swallows per-key errors
64
65
  // by design — routing forget through it would report discarded:true
@@ -27,6 +27,7 @@ export const saveDraftWrite = defineWriteHandler({
27
27
  access: FORM_DRAFT_ACCESS,
28
28
  description: "Upserts the calling user's draft for one draftKey with the given form values and step index, stamping savedAt server-side and refusing a brand-new draft once the per-owner draft cap is reached; use it to persist an in-progress form before the real entity exists.",
29
29
  escapeHatch: {
30
+ grants: ["unsafeRaw"],
30
31
  reason: SAVE_DRAFT_COUNT_REASON,
31
32
  },
32
33
  handler: async (event, ctx) => {
@@ -41,7 +42,7 @@ export const saveDraftWrite = defineWriteHandler({
41
42
  // Cap check only on the create path — an update never grows the number
42
43
  // of drafts this owner has, so a user already at the limit can still
43
44
  // keep saving their existing drafts.
44
- const draftCount = await countDraftsByOwner(ctx.db.unsafeRaw(SAVE_DRAFT_COUNT_REASON), event.user.tenantId, ownerId);
45
+ const draftCount = await countDraftsByOwner(ctx.db.unsafeRaw(), event.user.tenantId, ownerId);
45
46
  if (draftCount >= FORM_DRAFT_MAX_PER_OWNER) {
46
47
  return failUnprocessable("draft_limit_reached", { limit: FORM_DRAFT_MAX_PER_OWNER });
47
48
  }