@cosmicdrift/kumiko-bundled-features 0.364.0 → 0.365.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 (161) hide show
  1. package/dist/audit/feature.js +1 -1
  2. package/dist/audit/run-escape-hatch-retention.d.ts +1 -0
  3. package/dist/audit/run-escape-hatch-retention.js +39 -12
  4. package/dist/auth-email-password/bootstrap.d.ts +1 -3
  5. package/dist/auth-email-password/feature.js +2 -0
  6. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -3
  7. package/dist/auth-email-password/handlers/invite-create.write.d.ts +2 -3
  8. package/dist/auth-email-password/handlers/invite-create.write.js +4 -1
  9. package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.d.ts +1 -0
  10. package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.js +1 -1
  11. package/dist/auth-email-password/magic-link-mail.js +6 -3
  12. package/dist/auth-email-password/mail-i18n.d.ts +3 -0
  13. package/dist/auth-email-password/mail-i18n.js +10 -0
  14. package/dist/billing-foundation/consumer-protection/termination-record.js +8 -1
  15. package/dist/billing-foundation/i18n.js +6 -0
  16. package/dist/billing-foundation/subscription-tier-sync.js +8 -2
  17. package/dist/cap-counter/book-cap-usage.js +8 -0
  18. package/dist/cap-counter/cap-reservation.js +7 -3
  19. package/dist/cap-counter/handlers/increment-rolling.write.d.ts +1 -0
  20. package/dist/cap-counter/handlers/increment-rolling.write.js +2 -0
  21. package/dist/cap-overview/handlers/tenant-caps-list.query.js +3 -1
  22. package/dist/cap-overview/handlers/tenant-options.query.js +3 -1
  23. package/dist/channel-discord/discord-channel.js +3 -2
  24. package/dist/channel-email/email-channel.js +24 -19
  25. package/dist/channel-email/smtp-transport.js +6 -1
  26. package/dist/channel-slack/slack-channel.js +3 -2
  27. package/dist/channel-teams/teams-channel.js +3 -2
  28. package/dist/crypto-shredding/handlers/forget-subject.write.js +2 -2
  29. package/dist/custom-fields/db/queries/retention.d.ts +1 -1
  30. package/dist/custom-fields/db/queries/retention.js +2 -1
  31. package/dist/data-retention/run-retention-cleanup.js +4 -5
  32. package/dist/delivery/attachment-ref.js +16 -1
  33. package/dist/delivery/delivery-service.js +71 -82
  34. package/dist/feature-toggles/toggle-runtime.d.ts +5 -3
  35. package/dist/feature-toggles/toggle-runtime.js +25 -12
  36. package/dist/files-tenant-data/index.d.ts +1 -0
  37. package/dist/files-tenant-data/index.js +2 -1
  38. package/dist/form-draft/db/queries/owned-file-refs.d.ts +1 -1
  39. package/dist/form-draft/db/queries/owned-file-refs.js +9 -2
  40. package/dist/form-draft/handlers/discard.write.js +1 -1
  41. package/dist/jobs/job-run-logger.js +5 -2
  42. package/dist/metrics/filters.d.ts +16 -1
  43. package/dist/metrics/filters.js +27 -11
  44. package/dist/personal-access-tokens/revoke-for-user.js +3 -7
  45. package/dist/secrets/handlers/delete.write.js +3 -1
  46. package/dist/secrets/write-gate.d.ts +3 -1
  47. package/dist/secrets/write-gate.js +6 -3
  48. package/dist/shared/index.d.ts +1 -0
  49. package/dist/shared/index.js +1 -0
  50. package/dist/shared/resource-image-formats.d.ts +23 -0
  51. package/dist/shared/resource-image-formats.js +21 -0
  52. package/dist/shared/sharp-input-limits.js +7 -8
  53. package/dist/step-dispatcher/webhook-runner.js +2 -2
  54. package/dist/template-resolver/compile-document-body.d.ts +3 -0
  55. package/dist/template-resolver/compile-document-body.js +63 -12
  56. package/dist/template-resolver/compile-steps-block.d.ts +10 -0
  57. package/dist/template-resolver/compile-steps-block.js +45 -0
  58. package/dist/template-resolver/constants.d.ts +4 -1
  59. package/dist/template-resolver/constants.js +10 -1
  60. package/dist/template-resolver/document-sanitizer.js +3 -0
  61. package/dist/template-resolver/document-schema.d.ts +2 -1
  62. package/dist/template-resolver/document-schema.js +6 -1
  63. package/dist/template-resolver/document-steps.d.ts +17 -0
  64. package/dist/template-resolver/document-steps.js +93 -0
  65. package/dist/template-resolver/feature.js +9 -0
  66. package/dist/template-resolver/handlers/bundle-import.d.ts +7 -1
  67. package/dist/template-resolver/handlers/bundle-import.js +63 -38
  68. package/dist/template-resolver/handlers/collection-set.write.js +4 -3
  69. package/dist/template-resolver/handlers/copy-locale.write.d.ts +1 -1
  70. package/dist/template-resolver/handlers/copy-locale.write.js +83 -55
  71. package/dist/template-resolver/handlers/diff-to-system.query.js +1 -0
  72. package/dist/template-resolver/handlers/import-bundle-system.write.d.ts +1 -1
  73. package/dist/template-resolver/handlers/import-bundle-system.write.js +16 -5
  74. package/dist/template-resolver/handlers/import-bundle.write.d.ts +1 -1
  75. package/dist/template-resolver/handlers/import-bundle.write.js +11 -1
  76. package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
  77. package/dist/template-resolver/handlers/overview.query.js +9 -1
  78. package/dist/template-resolver/handlers/resource-upload.write.d.ts +2 -2
  79. package/dist/template-resolver/handlers/resource-upload.write.js +35 -5
  80. package/dist/template-resolver/handlers/validate.query.js +4 -1
  81. package/dist/template-resolver/index.d.ts +3 -3
  82. package/dist/template-resolver/index.js +3 -3
  83. package/dist/template-resolver/layout-appearance.d.ts +1 -1
  84. package/dist/template-resolver/layout-appearance.js +19 -9
  85. package/dist/template-resolver/mail-constants.d.ts +2 -0
  86. package/dist/template-resolver/mail-constants.js +3 -0
  87. package/dist/template-resolver/resource-image.d.ts +2 -9
  88. package/dist/template-resolver/resource-image.js +2 -11
  89. package/dist/template-resolver/seeding.js +2 -0
  90. package/dist/template-resolver/store-template-resource.d.ts +53 -9
  91. package/dist/template-resolver/store-template-resource.js +250 -97
  92. package/dist/template-resolver/template-bundle.js +4 -0
  93. package/dist/template-resolver/template-draft.d.ts +3 -0
  94. package/dist/template-resolver/template-draft.js +1 -1
  95. package/dist/template-resolver/template-meta.d.ts +5 -1
  96. package/dist/template-resolver/template-meta.js +7 -0
  97. package/dist/template-resolver/template-preview.js +1 -2
  98. package/dist/template-resolver/template-resources.d.ts +0 -5
  99. package/dist/template-resolver/template-resources.js +2 -34
  100. package/dist/template-resolver/template-validation.js +12 -9
  101. package/dist/template-resolver/web/document-editor-extensions.d.ts +1 -0
  102. package/dist/template-resolver/web/document-editor-extensions.js +26 -1
  103. package/dist/template-resolver/web/document-editor.js +5 -1
  104. package/dist/template-resolver/web/document-tiptap-editor.js +30 -7
  105. package/dist/template-resolver/web/document-toolbar-blocks.d.ts +5 -5
  106. package/dist/template-resolver/web/document-toolbar-blocks.js +2 -0
  107. package/dist/template-resolver/web/i18n.js +5 -0
  108. package/dist/template-resolver/web/index.d.ts +1 -0
  109. package/dist/template-resolver/web/index.js +1 -0
  110. package/dist/template-resolver/web/placeholder-line-editor.d.ts +4 -2
  111. package/dist/template-resolver/web/placeholder-line-editor.js +8 -6
  112. package/dist/tenant/handlers/update-member-roles.write.js +3 -6
  113. package/dist/tenant/seeding.js +23 -24
  114. package/dist/tenant-handover/move-entity-graph.js +14 -4
  115. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +1 -0
  116. package/dist/tenant-lifecycle/run-tenant-destroy.js +8 -1
  117. package/dist/tenant-lifecycle/stages.js +3 -2
  118. package/dist/tier-engine/constants.d.ts +2 -0
  119. package/dist/tier-engine/constants.js +4 -0
  120. package/dist/tier-engine/entity.d.ts +15 -14
  121. package/dist/tier-engine/entity.js +4 -3
  122. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +5 -0
  123. package/dist/tier-engine/handlers/set-tenant-tier.write.js +7 -4
  124. package/dist/tier-engine/index.d.ts +1 -1
  125. package/dist/tier-engine/index.js +1 -1
  126. package/dist/tier-engine/tier-resolver.d.ts +13 -13
  127. package/dist/user/constants.d.ts +1 -0
  128. package/dist/user/invited-global-roles.d.ts +2 -4
  129. package/dist/user-data-rights/email-templates.js +0 -1
  130. package/dist/user-data-rights/i18n.js +5 -0
  131. package/dist/user-data-rights/lib/update-user-lifecycle.d.ts +2 -0
  132. package/dist/user-data-rights/lib/update-user-lifecycle.js +6 -2
  133. package/dist/user-data-rights/run-forget-cleanup.js +2 -2
  134. package/dist/waitlist/feature.js +1 -1
  135. package/dist/waitlist/handlers/invite.write.js +21 -3
  136. package/dist/waitlist/handlers/reject.write.js +3 -16
  137. package/dist/waitlist/handlers/submit.write.js +29 -7
  138. package/dist/waitlist/i18n.js +9 -0
  139. package/dist/waitlist/lib.d.ts +2 -1
  140. package/dist/waitlist/lib.js +15 -0
  141. package/dist/workflow-runner/feature.js +1 -1
  142. package/package.json +10 -10
  143. package/src/audit/changes.json +12 -0
  144. package/src/auth-email-password/changes.json +18 -0
  145. package/src/billing-foundation/changes.json +12 -0
  146. package/src/cap-counter/changes.json +13 -0
  147. package/src/cap-overview/changes.json +6 -0
  148. package/src/channel-email/changes.json +18 -0
  149. package/src/delivery/changes.json +12 -0
  150. package/src/document-ingest-foundation/changes.json +6 -0
  151. package/src/feature-toggles/changes.json +7 -0
  152. package/src/jobs/changes.json +12 -0
  153. package/src/metrics/changes.json +6 -0
  154. package/src/personal-access-tokens/changes.json +6 -0
  155. package/src/secrets/changes.json +6 -0
  156. package/src/template-resolver/changes.json +81 -0
  157. package/src/tenant/changes.json +6 -0
  158. package/src/tenant-lifecycle/changes.json +6 -0
  159. package/src/tier-engine/changes.json +12 -0
  160. package/src/user-data-rights/changes.json +6 -0
  161. package/src/waitlist/changes.json +18 -0
@@ -40,7 +40,7 @@
40
40
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
41
41
  import { collectPiiSubjectFields, configuredPiiSubjectKms, isSelfPiiField, resolveSubjectForField, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
42
42
  import { createEventStoreExecutor, createTenantDb, nullBlindIndexesForSubject, WritePurposes, } from "@cosmicdrift/kumiko-framework/db";
43
- import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
43
+ import { deleteFileRefBytes } from "@cosmicdrift/kumiko-framework/derivatives";
44
44
  import { createSystemUser, entityHasAnonymizableSubjectField, } from "@cosmicdrift/kumiko-framework/engine";
45
45
  import { fileRefEntity, fileRefsTable, } from "@cosmicdrift/kumiko-framework/files";
46
46
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
@@ -250,9 +250,9 @@ async function isFileRefSharedByOtherRow(db, holders, ownTable, tenantId, rowId,
250
250
  }
251
251
  // Throws (unsafe key, missing list permission) must fail this row closed,
252
252
  // not abort the whole tenant run.
253
- async function deleteFileFailingClosed(key, store, logContext) {
253
+ async function deleteFileFailingClosed(key, files, logContext) {
254
254
  try {
255
- return await deleteStoredFileAndDerivatives(key, store, "data-retention:hardDelete");
255
+ return await deleteFileRefBytes(files, { storageKey: key }, "data-retention:hardDelete");
256
256
  }
257
257
  catch (err) {
258
258
  // biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
@@ -304,13 +304,12 @@ async function purgeHardDeleteRowWithFiles(args) {
304
304
  return false;
305
305
  }
306
306
  const files = args.files;
307
- const store = { list: files.list, delete: (key) => files.ref(key).delete() };
308
307
  let anyFailed = false;
309
308
  for (const fileRef of toDelete) {
310
309
  const key = fileRef["storageKey"];
311
310
  if (typeof key !== "string" || key.length === 0)
312
311
  continue;
313
- const failedKeys = await deleteFileFailingClosed(key, store, `tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`);
312
+ const failedKeys = await deleteFileFailingClosed(key, files, `tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`);
314
313
  if (failedKeys.length > 0) {
315
314
  anyFailed = true;
316
315
  // biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
@@ -33,13 +33,28 @@ export const fileAttachmentRefSchema = z
33
33
  fileName: attachmentFileNameSchema,
34
34
  })
35
35
  .strict();
36
+ // BigInt values and cycles make JSON.stringify throw; that must surface as a validation issue,
37
+ // not a raw TypeError that a retrying job hits again on the same data.
38
+ function isJsonSerializable(value) {
39
+ try {
40
+ JSON.stringify(value);
41
+ return true;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ function jsonByteLength(value) {
48
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
49
+ }
36
50
  export const renderedPdfAttachmentRefSchema = z
37
51
  .object({
38
52
  kind: z.literal("rendered-pdf"),
39
53
  templateSlug: templateSlugSchema,
40
54
  variables: z
41
55
  .record(z.string(), z.unknown())
42
- .refine((value) => Buffer.byteLength(JSON.stringify(value)) <= MAX_RENDERED_PDF_VARIABLES_BYTES, {
56
+ .refine(isJsonSerializable, { message: "variables must be JSON-serializable" })
57
+ .refine((value) => !isJsonSerializable(value) || jsonByteLength(value) <= MAX_RENDERED_PDF_VARIABLES_BYTES, {
43
58
  message: `variables must not exceed ${MAX_RENDERED_PDF_VARIABLES_BYTES} bytes as JSON`,
44
59
  }),
45
60
  fileName: attachmentFileNameSchema.refine((name) => name.toLowerCase().endsWith(".pdf"), {
@@ -79,11 +79,20 @@ export function createDeliveryService(options) {
79
79
  }
80
80
  // A key that guarded nothing must not turn the caller's retry into a skipped duplicate.
81
81
  // Partial success keeps it: a retry would re-send to those who got it.
82
- async function releaseIdempotencyIfNothingDelivered(tenantId, key, run) {
82
+ // A failing release is logged and swallowed: it must neither replace the dispatch error the
83
+ // caller retries on nor fail a notify() that already dispatched.
84
+ async function releaseIdempotencyIfNothingDelivered(tenantId, key, run, notificationType) {
83
85
  // skip: no key claimed, or a recipient already got the notification
84
86
  if (!key || run.deliveries.some(isDeliveredOrQueued))
85
87
  return;
86
- await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
88
+ try {
89
+ await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
90
+ }
91
+ catch (releaseErr) {
92
+ logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
93
+ notificationType,
94
+ });
95
+ }
87
96
  }
88
97
  // Only a single-channel route lets the queued job free the key on terminal failure
89
98
  // without risking a duplicate send on another channel.
@@ -290,35 +299,50 @@ export function createDeliveryService(options) {
290
299
  return addressHash !== undefined && suppressed.has(addressOptOutKey(addressHash, channelName));
291
300
  }
292
301
  const recipientChannelKey = (userId, channelName) => `${userId}|${channelName}`;
293
- async function isChannelDeliverable(run, userId, tenantId, notificationType, channelName) {
294
- if (isChannelKilled && (await isChannelKilled(tenantId, channelName)))
295
- return false;
296
- return isChannelEnabled(run, userId, tenantId, notificationType, channelName);
302
+ // Kill switch, then the user's preference, then the rate limit — the order deliverToUser reports
303
+ // skips in. The rate limit consumes a token, so each recipient/channel pair is evaluated once.
304
+ async function evaluateChannelGate(run, userId, tenantId, notificationType, channelName, priority) {
305
+ if (isChannelKilled && (await isChannelKilled(tenantId, channelName))) {
306
+ return "channel_disabled";
307
+ }
308
+ // Critical priority skips the preference check
309
+ if (priority !== "critical" &&
310
+ !(await isChannelEnabled(run, userId, tenantId, notificationType, channelName))) {
311
+ return "preference_disabled";
312
+ }
313
+ if (rateLimit && !(await checkRateLimit(rateLimit, tenantId, channelName))) {
314
+ return "rate_limited";
315
+ }
316
+ return "open";
297
317
  }
298
318
  // Resolves every user's channel addresses up front so the opt-out lookup can
299
- // cover them all at once. A resolve that throws stays out of the map: the
300
- // delivery loop resolves it again and reports channel_error as before.
301
- // Channels the kill switch or the user's preferences disable are never resolved
302
- // here: resolve may have side effects and deliverToUser skips those channels.
303
- async function prefetchUserAddresses(run, userIds, tenantId, notificationType) {
319
+ // cover them all at once. A resolve that throws stays out of the address map:
320
+ // the delivery loop resolves it again and reports channel_error as before.
321
+ // Channels that are gated (kill switch, preferences, rate limit) are never resolved
322
+ // here: resolve may have side effects and deliverToUser skips those channels. The
323
+ // gate verdict is kept so deliverToUser does not query it a second time.
324
+ async function prefetchRecipients(run, userIds, tenantId, notificationType, priority) {
304
325
  const addresses = new Map();
326
+ const gates = new Map();
305
327
  const channelCtx = buildChannelContext(run.db, registry, sseBroker, tenantId, secrets, tenantConfigFor?.(tenantId), run.filesForTenant);
306
328
  for (const userId of userIds) {
307
329
  for (const channel of channels) {
308
330
  if (!channel.resolve)
309
331
  continue;
332
+ const key = recipientChannelKey(userId, channel.name);
310
333
  try {
311
- if (!(await isChannelDeliverable(run, userId, tenantId, notificationType, channel.name))) {
334
+ const gate = await evaluateChannelGate(run, userId, tenantId, notificationType, channel.name, priority);
335
+ gates.set(key, gate);
336
+ if (gate !== "open")
312
337
  continue;
313
- }
314
- addresses.set(recipientChannelKey(userId, channel.name), await channel.resolve(userId, channelCtx));
338
+ addresses.set(key, await channel.resolve(userId, channelCtx));
315
339
  }
316
340
  catch {
317
341
  // skip: re-resolved (and logged) by deliverToUser
318
342
  }
319
343
  }
320
344
  }
321
- return addresses;
345
+ return { addresses, gates };
322
346
  }
323
347
  // Check if user has disabled this notification+channel combo.
324
348
  // Specificity order: exact > any wildcard. When only wildcards match and they
@@ -341,68 +365,33 @@ export function createDeliveryService(options) {
341
365
  // and conservative — DB ordering no longer decides the outcome).
342
366
  return !prefs.some((p) => p.enabled === false);
343
367
  }
344
- async function deliverToUser(userId, notificationType, data, tenantId, priority, run, prefetchedAddresses, suppressed) {
368
+ async function deliverToUser(userId, notificationType, data, tenantId, priority, run, prefetched, suppressed) {
345
369
  const channelCtx = buildChannelContext(run.db, registry, sseBroker, tenantId, secrets, tenantConfigFor?.(tenantId), run.filesForTenant);
346
370
  for (const channel of channels) {
347
371
  // Route-only channel (no per-user address): not a user-notification target.
348
372
  if (!channel.resolve)
349
373
  continue;
350
374
  const message = buildMessage(notificationType, data, channel.name, run.locale);
351
- // Kill switch: tenant admin disabled this channel entirely
352
- if (isChannelKilled) {
353
- const killed = await isChannelKilled(tenantId, channel.name);
354
- if (killed) {
355
- await logDelivery(run, {
356
- tenantId,
357
- notificationType,
358
- channel: channel.name,
359
- recipientId: userId,
360
- recipientAddress: null,
361
- status: "skipped",
362
- error: "channel_disabled",
363
- priority,
364
- });
365
- continue;
366
- }
367
- }
368
- // Check preferences (critical priority skips preference check)
369
- if (priority !== "critical") {
370
- const enabled = await isChannelEnabled(run, userId, tenantId, notificationType, channel.name);
371
- if (!enabled) {
372
- await logDelivery(run, {
373
- tenantId,
374
- notificationType,
375
- channel: channel.name,
376
- recipientId: userId,
377
- recipientAddress: null,
378
- status: "skipped",
379
- error: "preference_disabled",
380
- priority,
381
- });
382
- continue;
383
- }
384
- }
385
- // Rate limiting
386
- if (rateLimit) {
387
- const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
388
- if (!allowed) {
389
- await logDelivery(run, {
390
- tenantId,
391
- notificationType,
392
- channel: channel.name,
393
- recipientId: userId,
394
- recipientAddress: null,
395
- status: "skipped",
396
- error: "rate_limited",
397
- priority,
398
- });
399
- continue;
400
- }
375
+ const prefetchKey = recipientChannelKey(userId, channel.name);
376
+ const gate = prefetched.gates.get(prefetchKey) ??
377
+ (await evaluateChannelGate(run, userId, tenantId, notificationType, channel.name, priority));
378
+ if (gate !== "open") {
379
+ await logDelivery(run, {
380
+ tenantId,
381
+ notificationType,
382
+ channel: channel.name,
383
+ recipientId: userId,
384
+ recipientAddress: null,
385
+ status: "skipped",
386
+ error: gate,
387
+ priority,
388
+ });
389
+ continue;
401
390
  }
402
391
  try {
403
- const prefetchKey = recipientChannelKey(userId, channel.name);
404
- const address = prefetchedAddresses.has(prefetchKey)
405
- ? prefetchedAddresses.get(prefetchKey)
392
+ const wasPrefetched = prefetched.addresses.has(prefetchKey);
393
+ const address = wasPrefetched
394
+ ? prefetched.addresses.get(prefetchKey)
406
395
  : await channel.resolve(userId, channelCtx);
407
396
  if (!address) {
408
397
  await logDelivery(run, {
@@ -417,7 +406,12 @@ export function createDeliveryService(options) {
417
406
  });
418
407
  continue;
419
408
  }
420
- if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
409
+ // A fallback resolve yields an address the batched opt-out lookup never covered.
410
+ const suppressedForAddress = wasPrefetched
411
+ ? suppressed
412
+ : await loadSuppressedAddresses(run, tenantId, notificationType, priority, [address]);
413
+ if (priority !== "critical" &&
414
+ isAddressSuppressed(suppressedForAddress, address, channel.name)) {
421
415
  await logDelivery(run, {
422
416
  tenantId,
423
417
  notificationType,
@@ -551,12 +545,12 @@ export function createDeliveryService(options) {
551
545
  else {
552
546
  userIds = to;
553
547
  }
554
- const prefetchedAddresses = priority === "critical"
555
- ? new Map()
556
- : await prefetchUserAddresses(run, userIds, tenantId, notificationType);
557
- const suppressed = await loadSuppressedAddresses(run, tenantId, notificationType, priority, [...prefetchedAddresses.values()].filter((address) => !!address));
548
+ const prefetched = priority === "critical"
549
+ ? { addresses: new Map(), gates: new Map() }
550
+ : await prefetchRecipients(run, userIds, tenantId, notificationType, priority);
551
+ const suppressed = await loadSuppressedAddresses(run, tenantId, notificationType, priority, [...prefetched.addresses.values()].filter((address) => !!address));
558
552
  for (const userId of userIds) {
559
- await deliverToUser(userId, notificationType, data, tenantId, priority, run, prefetchedAddresses, suppressed);
553
+ await deliverToUser(userId, notificationType, data, tenantId, priority, run, prefetched, suppressed);
560
554
  }
561
555
  }
562
556
  }
@@ -599,15 +593,10 @@ export function createDeliveryService(options) {
599
593
  await dispatchNotification(notificationType, options, tenantId, priority, run);
600
594
  }
601
595
  catch (err) {
602
- // A failing release must not replace the dispatch error the caller retries on.
603
- await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run).catch((releaseErr) => {
604
- logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
605
- notificationType,
606
- });
607
- });
596
+ await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run, notificationType);
608
597
  throw err;
609
598
  }
610
- await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run);
599
+ await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run, notificationType);
611
600
  return dispatched(run);
612
601
  },
613
602
  };
@@ -1,6 +1,6 @@
1
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { type Registry } from "@cosmicdrift/kumiko-framework/engine";
3
- import { type ClosableCacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
3
+ import { type CacheSyncBus, type ClosableCacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
4
4
  export type ToggleSyncSignal = {
5
5
  publish(featureName: string, enabled: boolean): void;
6
6
  onMessage(listener: (featureName: string, enabled: boolean) => void): void;
@@ -12,14 +12,16 @@ export declare class GlobalFeatureToggleRuntime {
12
12
  private readonly syncSignal?;
13
13
  private readonly ownedBus?;
14
14
  private snapshot;
15
- private appliedDuringLoad;
15
+ private readonly pendingFlipBuffers;
16
+ private loadInFlight;
16
17
  constructor(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal | undefined, ownedBus?: ClosableCacheSyncBus | undefined);
17
18
  close(): Promise<void>;
18
19
  initialize(): Promise<void>;
20
+ private loadSnapshot;
19
21
  refresh(): Promise<void>;
20
22
  apply(featureName: string, enabled: boolean): void;
21
23
  broadcastToggle(featureName: string, enabled: boolean): void;
22
24
  readOverride(featureName: string): boolean | undefined;
23
25
  effectiveFeatures: () => ReadonlySet<string>;
24
26
  }
25
- export declare function createFeatureToggleRuntime(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal): Promise<GlobalFeatureToggleRuntime>;
27
+ export declare function createFeatureToggleRuntime(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal, cacheSyncBus?: CacheSyncBus): Promise<GlobalFeatureToggleRuntime>;
@@ -22,9 +22,11 @@ export class GlobalFeatureToggleRuntime {
22
22
  syncSignal;
23
23
  ownedBus;
24
24
  snapshot = new Map();
25
- // Flips applied while a snapshot load is in flight. The load's SELECT may have
26
- // started before the flip committed, so its rows can be older than these.
27
- appliedDuringLoad;
25
+ // One buffer per requested load, open from the call until its SELECT finished: the SELECT
26
+ // may have started before a flip committed, so its rows can be older than the flips collected.
27
+ pendingFlipBuffers = new Set();
28
+ // Loads run one at a time so the older rows of one cannot overwrite the result of another.
29
+ loadInFlight = Promise.resolve();
28
30
  constructor(db, registry, syncSignal, ownedBus) {
29
31
  this.db = db;
30
32
  this.registry = registry;
@@ -47,9 +49,14 @@ export class GlobalFeatureToggleRuntime {
47
49
  async close() {
48
50
  await this.ownedBus?.close();
49
51
  }
50
- async initialize() {
52
+ initialize() {
51
53
  const pendingFlips = new Map();
52
- this.appliedDuringLoad = pendingFlips;
54
+ this.pendingFlipBuffers.add(pendingFlips);
55
+ const load = this.loadInFlight.then(() => this.loadSnapshot(pendingFlips));
56
+ this.loadInFlight = load.catch(() => undefined);
57
+ return load;
58
+ }
59
+ async loadSnapshot(pendingFlips) {
53
60
  try {
54
61
  const rows = await selectMany(this.db, globalFeatureStateTable);
55
62
  this.snapshot = new Map([
@@ -58,7 +65,7 @@ export class GlobalFeatureToggleRuntime {
58
65
  ]);
59
66
  }
60
67
  finally {
61
- this.appliedDuringLoad = undefined;
68
+ this.pendingFlipBuffers.delete(pendingFlips);
62
69
  }
63
70
  }
64
71
  // Re-read the full snapshot. Called from the set-handler after a
@@ -74,7 +81,8 @@ export class GlobalFeatureToggleRuntime {
74
81
  // lock. Kept alongside refresh() so both options are explicit.
75
82
  apply(featureName, enabled) {
76
83
  this.snapshot.set(featureName, enabled);
77
- this.appliedDuringLoad?.set(featureName, enabled);
84
+ for (const pendingFlips of this.pendingFlipBuffers)
85
+ pendingFlips.set(featureName, enabled);
78
86
  }
79
87
  // Called by the toggle-cache-sync MSP handler (feature.ts) — the one
80
88
  // process whose shared cursor won a given toggle-set event. With a
@@ -116,11 +124,16 @@ export class GlobalFeatureToggleRuntime {
116
124
  }
117
125
  // Factory for app-boot wiring: instantiate, initialize, return the runtime (for the
118
126
  // set-handler to refresh; its effectiveFeatures feeds createDispatcher). Without an explicit
119
- // syncSignal the runtime builds one on the framework cache-sync bus: Redis-backed when
120
- // REDIS_URL is set, process-local otherwise. close() releases a bus it created.
121
- export async function createFeatureToggleRuntime(db, registry, syncSignal) {
122
- const ownedBus = syncSignal ? undefined : createDefaultCacheSyncBus();
123
- const signal = syncSignal ?? (ownedBus ? createCacheSyncToggleSignal(ownedBus) : undefined);
127
+ // syncSignal the signal runs on the given cacheSyncBus or, failing that, on a bus the runtime
128
+ // creates itself (Redis-backed when REDIS_URL is set, process-local otherwise). close() releases
129
+ // only a bus the runtime created.
130
+ export async function createFeatureToggleRuntime(db, registry, syncSignal,
131
+ // The server's own bus (the one handed to the dispatcher): reusing it avoids a second set of
132
+ // Redis connections that nothing closes at shutdown.
133
+ cacheSyncBus) {
134
+ const ownedBus = syncSignal || cacheSyncBus ? undefined : createDefaultCacheSyncBus();
135
+ const sharedBus = cacheSyncBus ?? ownedBus;
136
+ const signal = syncSignal ?? (sharedBus ? createCacheSyncToggleSignal(sharedBus) : undefined);
124
137
  const runtime = new GlobalFeatureToggleRuntime(db, registry, signal, ownedBus);
125
138
  try {
126
139
  await runtime.initialize();
@@ -1,4 +1,5 @@
1
1
  import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare const FILES_TENANT_DATA_FEATURE: "files-tenant-data";
2
3
  export declare function createFilesTenantDataFeature(): FeatureDefinition;
3
4
  export { sweepOrphanedDerivativesJob, sweepOrphanedDerivativesPayloadSchema, } from "./handlers/sweep-orphaned-derivatives.job.js";
4
5
  export { fileRefStorageDestroyHook, fileRefTenantDestroyHook } from "./hooks.js";
@@ -14,8 +14,9 @@ import { defineFeature, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, } from "@cosmicdr
14
14
  import { sweepOrphanedDerivativesJob, sweepOrphanedDerivativesPayloadSchema, } from "./handlers/sweep-orphaned-derivatives.job.js";
15
15
  import { fileRefStorageDestroyHook, fileRefTenantDestroyHook } from "./hooks.js";
16
16
  const SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON = "iterates every tenant and checks fileRef owners per tenant";
17
+ export const FILES_TENANT_DATA_FEATURE = "files-tenant-data";
17
18
  export function createFilesTenantDataFeature() {
18
- return defineFeature("files-tenant-data", (r) => {
19
+ return defineFeature(FILES_TENANT_DATA_FEATURE, (r) => {
19
20
  r.describe("GDPR coverage for the `files` feature's `fileRef` entity: tenant-destroy row purge (EXT_TENANT_DATA) and full storage-prefix binary wipe (EXT_STORAGE_PROVIDER), plus a manual backfill/GC job that sweeps derivatives orphaned by a forget/tenant-destroy that ran before #2461 wired binary cleanup into those flows. Requires `tenant-lifecycle`, optionalRequires `files`.");
20
21
  r.uiHints({
21
22
  displayLabel: "Files · Tenant-Destroy & Derivative GC",
@@ -1,4 +1,4 @@
1
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
1
+ import { type DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
4
  export declare function filterOwnedStorageKeys(db: DbRunner, tenantId: TenantId, ownerId: string, candidateKeys: readonly string[], draftInsertedAt: Temporal.Instant, isCreateMode: boolean): Promise<readonly string[]>;
@@ -1,4 +1,5 @@
1
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { instantToTimestamptzParam, unsafeReadRetrying, } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { toPostgresArrayLiteral } from "@cosmicdrift/kumiko-framework/db";
2
3
  // Narrows a draft blob's raw FileRef-shaped storageKeys (collectDraftFileRefKeys
3
4
  // output — extracted from free-form, client-supplied JSON, issue #1889) down to
4
5
  // the ones with a real, non-deleted file_refs row owned by this exact
@@ -32,6 +33,12 @@ export async function filterOwnedFileRefs(db, tenantId, ownerId, candidateKeys,
32
33
  const rows = await unsafeReadRetrying(db, `SELECT "id", "storage_key" FROM "file_refs"
33
34
  WHERE "tenant_id" = $1 AND "inserted_by_id" = $2
34
35
  AND "storage_key" = ANY($3::text[]) AND "is_deleted" = false
35
- AND ($5::boolean OR "inserted_at" > $4::timestamptz)`, [tenantId, ownerId, candidateKeys, draftInsertedAt.toString(), isCreateMode]);
36
+ AND ($5::boolean OR "inserted_at" > $4::timestamptz)`, [
37
+ tenantId,
38
+ ownerId,
39
+ toPostgresArrayLiteral(candidateKeys),
40
+ instantToTimestamptzParam(draftInsertedAt),
41
+ isCreateMode,
42
+ ]);
36
43
  return rows.map((row) => ({ id: row.id, storageKey: row.storage_key }));
37
44
  }
@@ -76,7 +76,7 @@ export const discardDraftWrite = defineWriteHandler({
76
76
  const schedule = ctx.scheduleAfterCommit;
77
77
  const release = async () => {
78
78
  await releaseDraftFileRefs(keys, async (key) => {
79
- await files.ref(key).delete();
79
+ await files.refFromFileRef({ storageKey: key }).delete();
80
80
  }, log);
81
81
  };
82
82
  if (schedule) {
@@ -210,9 +210,12 @@ async function recordTenantRunOutcome(db, jobName, bullJobId, outcome, result) {
210
210
  // both behind.
211
211
  await transaction(db, async (tx) => {
212
212
  await acquireNamespacedAdvisoryLock(tx, TENANT_JOB_RUN_LOCK_NAMESPACE, JSON.stringify([key.tenantId, key.jobName, key.subject]));
213
- await upsertOnConflict(tx, tenantJobRunsTable, { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now }, {
213
+ // Read under the lock: the run that finishes last in lock order must also carry the
214
+ // latest finishedAt, otherwise a stale timestamp makes it the row that gets pruned.
215
+ const finishedAt = Temporal.Now.instant();
216
+ await upsertOnConflict(tx, tenantJobRunsTable, { ...key, bullJobId, status: result, finishedAt, updatedAt: finishedAt }, {
214
217
  conflictKeys: [...TENANT_RUN_CONFLICT_KEYS],
215
- update: { status: result, finishedAt: now, updatedAt: now },
218
+ update: { status: result, finishedAt, updatedAt: finishedAt },
216
219
  });
217
220
  await deleteMany(tx, tenantJobRunsTable, {
218
221
  ...key,
@@ -3,7 +3,21 @@ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
4
  import type { MetricDefinition, MetricFilterResolver } from "./types.js";
5
5
  export declare const RESERVED_PAYLOAD_KEYS: readonly string[];
6
- export type FilterValueKind = "uuid" | "number" | "string";
6
+ declare const INTEGER_RANGES: {
7
+ readonly smallint: {
8
+ readonly min: -32768;
9
+ readonly max: 32767;
10
+ };
11
+ readonly integer: {
12
+ readonly min: -2147483648;
13
+ readonly max: 2147483647;
14
+ };
15
+ readonly bigint: {
16
+ readonly min: number;
17
+ readonly max: number;
18
+ };
19
+ };
20
+ export type FilterValueKind = "uuid" | "number" | "string" | keyof typeof INTEGER_RANGES;
7
21
  /** Undefined = the column type cannot be filtered by a payload value without risking a Postgres cast error. */
8
22
  export declare function filterValueKindOf(pgType: string | undefined): FilterValueKind | undefined;
9
23
  export type MetricFilterEntry = {
@@ -17,3 +31,4 @@ export declare function metricFilterPayloadShape(metric: MetricDefinition): Reco
17
31
  /** Column-equality filters only; resolver filters need resolveMetricFilterWhere. */
18
32
  export declare function metricFilterWhere(metric: MetricDefinition, payload: Readonly<Record<string, unknown>>): WhereObject;
19
33
  export declare function resolveMetricFilterWhere(metric: MetricDefinition, payload: Readonly<Record<string, unknown>>, ctx: HandlerContext): Promise<WhereObject>;
34
+ export {};
@@ -3,17 +3,23 @@ import * as z from "zod";
3
3
  export const RESERVED_PAYLOAD_KEYS = ["range", "timeZone", "tenantId"];
4
4
  // Dashboard select options always send strings.
5
5
  const NUMERIC_STRING = /^-?\d+(\.\d+)?$/;
6
+ const INTEGER_STRING = /^-?\d+$/;
6
7
  const MAX_FILTER_STRING_LENGTH = 200;
7
- const NUMERIC_PG_TYPES = new Set([
8
- "integer",
9
- "int4",
10
- "smallint",
11
- "int2",
12
- "bigint",
13
- "serial",
14
- "bigserial",
15
- "double precision",
16
- ]);
8
+ const INTEGER_RANGES = {
9
+ smallint: { min: -32_768, max: 32_767 },
10
+ integer: { min: -2_147_483_648, max: 2_147_483_647 },
11
+ // bigint is bound as a JS number, so only the exactly representable range is accepted.
12
+ bigint: { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
13
+ };
14
+ const PG_INTEGER_KINDS = {
15
+ smallint: "smallint",
16
+ int2: "smallint",
17
+ integer: "integer",
18
+ int4: "integer",
19
+ serial: "integer",
20
+ bigint: "bigint",
21
+ bigserial: "bigint",
22
+ };
17
23
  /** Undefined = the column type cannot be filtered by a payload value without risking a Postgres cast error. */
18
24
  export function filterValueKindOf(pgType) {
19
25
  if (pgType === undefined)
@@ -22,7 +28,10 @@ export function filterValueKindOf(pgType) {
22
28
  return "uuid";
23
29
  if (pgType === "text")
24
30
  return "string";
25
- if (NUMERIC_PG_TYPES.has(pgType) || pgType.startsWith("numeric"))
31
+ const integerKind = PG_INTEGER_KINDS[pgType];
32
+ if (integerKind !== undefined)
33
+ return integerKind;
34
+ if (pgType === "double precision" || pgType.startsWith("numeric"))
26
35
  return "number";
27
36
  return undefined;
28
37
  }
@@ -31,9 +40,16 @@ export function metricFilterEntries(metric) {
31
40
  ? { payloadKey, column: spec }
32
41
  : { payloadKey, column: spec.column, resolver: spec });
33
42
  }
43
+ function integerValueSchema(range) {
44
+ const bounded = z.number().int().min(range.min).max(range.max);
45
+ return z.union([bounded, z.string().regex(INTEGER_STRING).transform(Number).pipe(bounded)]);
46
+ }
34
47
  function filterValueSchema(kind) {
35
48
  if (kind === "uuid")
36
49
  return z.uuid();
50
+ if (kind === "smallint" || kind === "integer" || kind === "bigint") {
51
+ return integerValueSchema(INTEGER_RANGES[kind]);
52
+ }
37
53
  if (kind === "number")
38
54
  return z.union([z.number(), z.string().regex(NUMERIC_STRING).transform(Number)]);
39
55
  return z.string().min(1).max(MAX_FILTER_STRING_LENGTH);
@@ -1,15 +1,10 @@
1
- import { transaction, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { runnerCanBeginTransaction, transaction, updateMany, } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
5
5
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
- import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
7
6
  import { PAT_REVOKED_AGGREGATE_TYPE, PAT_REVOKED_EVENT_QN, patRevokedSchema, } from "./pat-revoked-event.js";
8
7
  import { apiTokenTable } from "./schema/api-token.js";
9
- // A pool has begin(); a tx/savepoint handle does not and is already atomic.
10
- function canBeginTransaction(db) {
11
- return typeof propertyOf(db, "begin") === "function";
12
- }
13
8
  // Cross-tenant revoke: password-change and MFA-enable/disable are account-
14
9
  // level security events, not scoped to one tenant. Mirrors sessions'
15
10
  // sessionMassRevoker (session-callbacks.ts) which passes the boot-time
@@ -17,7 +12,8 @@ function canBeginTransaction(db) {
17
12
  export async function revokeAllPatTokensForUser(db, userId) {
18
13
  // Update and event commit together: otherwise a failed append leaves the
19
14
  // tokens revoked with no event, and a retry matches no live row.
20
- return canBeginTransaction(db)
15
+ // A pool has begin(); a tx/savepoint handle does not and is already atomic.
16
+ return runnerCanBeginTransaction(db)
21
17
  ? transaction(db, (tx) => revokeAndAppend(tx, userId))
22
18
  : revokeAndAppend(db, userId);
23
19
  }
@@ -16,7 +16,9 @@ export function createDeleteHandler(access = DEFAULT_SECRETS_ACCESS) {
16
16
  access,
17
17
  handler: async (event, ctx) => {
18
18
  const secrets = requireSecretsContext(ctx, "secrets:write:delete");
19
- const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, event.payload.key);
19
+ const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, event.payload.key, {
20
+ allowUndeclared: true,
21
+ });
20
22
  if (!gate.ok)
21
23
  return gate.failure;
22
24
  const removed = await secrets.delete(event.user.tenantId, event.payload.key, {
@@ -7,7 +7,9 @@ export type SecretKeyWriteCheck = {
7
7
  readonly ok: false;
8
8
  readonly failure: WriteFailure;
9
9
  };
10
- export declare function checkSecretKeyWrite(registry: Registry, userRoles: readonly string[], key: string): SecretKeyWriteCheck;
10
+ export declare function checkSecretKeyWrite(registry: Registry, userRoles: readonly string[], key: string, options?: {
11
+ readonly allowUndeclared?: boolean;
12
+ }): SecretKeyWriteCheck;
11
13
  export type SecretValueSchemaSource = Pick<Registry, "getSecretKey" | "findSecretNamespace">;
12
14
  export declare function invalidSecretValueError(): ValidationError;
13
15
  export declare function isInvalidSecretValueError(err: unknown): err is ValidationError;
@@ -15,11 +15,14 @@ function namespaceAcceptsKey(namespace, key) {
15
15
  // Only keys declared via r.secret() or under a declared r.secretNamespace()
16
16
  // are writable through the API, so a tenant admin cannot plant arbitrary rows
17
17
  // next to the feature-owned ones. writeRoles narrows the handler access
18
- // further for a key or a whole namespace.
19
- export function checkSecretKeyWrite(registry, userRoles, key) {
18
+ // further for a key or a whole namespace. Delete passes allowUndeclared: it
19
+ // creates nothing, and legacy rows under since-undeclared keys must stay removable.
20
+ export function checkSecretKeyWrite(registry, userRoles, key, options = {}) {
20
21
  const keyDef = registry.getSecretKey(key);
21
22
  const namespace = keyDef ? undefined : registry.findSecretNamespace(key);
22
- if (!keyDef && !(namespace && namespaceAcceptsKey(namespace, key))) {
23
+ if (!keyDef &&
24
+ !(namespace && namespaceAcceptsKey(namespace, key)) &&
25
+ options.allowUndeclared !== true) {
23
26
  return unknownKeyFailure(key);
24
27
  }
25
28
  const writeRoles = keyDef?.writeRoles ?? namespace?.writeRoles;