@cosmicdrift/kumiko-bundled-features 0.371.0 → 0.372.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 (92) hide show
  1. package/dist/auth-mfa/db/queries.d.ts +3 -2
  2. package/dist/auth-mfa/db/queries.js +2 -5
  3. package/dist/auth-mfa/schema/user-mfa.d.ts +10 -10
  4. package/dist/billing-foundation/entities.d.ts +10 -10
  5. package/dist/billing-foundation/projection.d.ts +10 -10
  6. package/dist/cap-counter/entity.d.ts +4 -4
  7. package/dist/cap-overview/web/cap-cards-panel.js +2 -1
  8. package/dist/compliance-profiles/schema/profile-selection.d.ts +4 -4
  9. package/dist/config/table.d.ts +6 -6
  10. package/dist/config/web/index.d.ts +1 -0
  11. package/dist/config/web/index.js +1 -0
  12. package/dist/crypto-shredding/handlers/forget-subject.write.js +55 -30
  13. package/dist/crypto-shredding/resolve-file-bytes-deleter.d.ts +3 -0
  14. package/dist/crypto-shredding/resolve-file-bytes-deleter.js +24 -0
  15. package/dist/custom-fields/entity.d.ts +9 -7
  16. package/dist/custom-fields/executor.d.ts +19 -1
  17. package/dist/data-retention/index.d.ts +1 -0
  18. package/dist/data-retention/index.js +1 -0
  19. package/dist/data-retention/run-retention-cleanup.js +27 -159
  20. package/dist/data-retention/schema/tenant-retention-override.d.ts +6 -6
  21. package/dist/delivery/delivery-service.d.ts +2 -1
  22. package/dist/delivery/delivery-service.js +3 -2
  23. package/dist/delivery/handlers/unsubscribe-page-chrome.query.js +2 -2
  24. package/dist/delivery/index.d.ts +1 -1
  25. package/dist/delivery/index.js +1 -1
  26. package/dist/delivery/jobs.js +21 -7
  27. package/dist/delivery/recipient-address-seal.d.ts +13 -0
  28. package/dist/delivery/recipient-address-seal.js +53 -0
  29. package/dist/delivery/tables.d.ts +8 -7
  30. package/dist/delivery/testing.d.ts +2 -1
  31. package/dist/delivery/testing.js +1 -0
  32. package/dist/delivery/unsubscribe.d.ts +13 -2
  33. package/dist/delivery/unsubscribe.js +9 -1
  34. package/dist/document-ingest-foundation/entity.d.ts +8 -8
  35. package/dist/document-ingest-foundation/executor.d.ts +16 -1
  36. package/dist/file-foundation/read-file-ref-content.js +2 -2
  37. package/dist/folders/entity.d.ts +8 -8
  38. package/dist/folders/executor.d.ts +29 -2
  39. package/dist/form-draft/entity.d.ts +3 -3
  40. package/dist/form-draft/executor.d.ts +15 -1
  41. package/dist/inbound-mail-foundation/entities.d.ts +31 -31
  42. package/dist/inbound-mail-foundation/projection.d.ts +24 -24
  43. package/dist/jobs/job-run-table.d.ts +10 -10
  44. package/dist/ledger/entity.d.ts +21 -21
  45. package/dist/ledger/executor.d.ts +57 -3
  46. package/dist/managed-pages/table.d.ts +16 -14
  47. package/dist/notes-history/entity.d.ts +14 -14
  48. package/dist/notes-history/executor.d.ts +31 -2
  49. package/dist/personal-access-tokens/schema/api-token.d.ts +9 -9
  50. package/dist/personal-access-tokens/screens.js +14 -9
  51. package/dist/secrets/table.d.ts +2 -2
  52. package/dist/sessions/schema/user-session.d.ts +8 -8
  53. package/dist/shared/index.d.ts +2 -0
  54. package/dist/shared/index.js +2 -0
  55. package/dist/shared/purge-files-reaching-subject.d.ts +25 -0
  56. package/dist/shared/purge-files-reaching-subject.js +146 -0
  57. package/dist/shared/purge-row-file-refs.d.ts +33 -0
  58. package/dist/shared/purge-row-file-refs.js +152 -0
  59. package/dist/tags/entity.d.ts +9 -9
  60. package/dist/tags/executor.d.ts +30 -2
  61. package/dist/template-resolver/handlers/shared.d.ts +28 -1
  62. package/dist/template-resolver/table.d.ts +32 -32
  63. package/dist/template-resolver/template-preview.js +13 -4
  64. package/dist/template-resolver/user-content-table.d.ts +16 -16
  65. package/dist/template-resolver/web/client-plugin.js +2 -1
  66. package/dist/template-resolver/web/document-editor-extensions.d.ts +1 -1
  67. package/dist/template-resolver/web/document-editor-extensions.js +2 -5
  68. package/dist/template-resolver/web/placeholder-line-editor.js +2 -2
  69. package/dist/tenant/handlers/list.query.d.ts +21 -1
  70. package/dist/tenant/invitation-table.d.ts +14 -14
  71. package/dist/tenant/membership-table.d.ts +4 -4
  72. package/dist/tenant/schema/tenant.d.ts +20 -18
  73. package/dist/tier-engine/entity.d.ts +2 -2
  74. package/dist/tier-engine/tier-resolver.d.ts +2 -2
  75. package/dist/user/handlers/me.query.d.ts +21 -0
  76. package/dist/user/schema/user.d.ts +24 -22
  77. package/dist/user-data-rights/run-export-jobs.d.ts +20 -1
  78. package/dist/user-data-rights/run-forget-cleanup.js +35 -1
  79. package/dist/user-data-rights/schema/download-attempt.d.ts +16 -16
  80. package/dist/user-data-rights/schema/download-token.d.ts +16 -16
  81. package/dist/user-data-rights/schema/export-job.d.ts +20 -20
  82. package/dist/user-data-rights/web/privacy-center-screen.js +2 -1
  83. package/dist/waitlist/entity.d.ts +24 -24
  84. package/dist/waitlist/lib.d.ts +24 -1
  85. package/package.json +11 -11
  86. package/src/crypto-shredding/changes.json +6 -0
  87. package/src/data-retention/changes.json +6 -0
  88. package/src/delivery/changes.json +13 -0
  89. package/src/files/changes.json +7 -0
  90. package/src/personal-access-tokens/changes.json +12 -0
  91. package/src/template-resolver/changes.json +6 -0
  92. package/src/user-data-rights/changes.json +6 -0
@@ -1 +1,19 @@
1
- export declare const fieldDefinitionExecutor: import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor;
1
+ export declare const fieldDefinitionExecutor: import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor<{
2
+ readonly id: string;
3
+ readonly tenantId: string;
4
+ readonly version: number;
5
+ readonly insertedAt: Temporal.Instant;
6
+ readonly modifiedAt: Temporal.Instant | null;
7
+ readonly insertedById: string | null;
8
+ readonly modifiedById: string | null;
9
+ readonly isDeleted?: boolean | undefined;
10
+ readonly deletedAt?: Temporal.Instant | null | undefined;
11
+ readonly deletedById?: string | null | undefined;
12
+ readonly displayOrder: number;
13
+ readonly entityName: string;
14
+ readonly fieldKey: string;
15
+ readonly required: boolean;
16
+ readonly searchable: boolean;
17
+ readonly serializedField: string;
18
+ readonly type: string;
19
+ }>;
@@ -1,3 +1,4 @@
1
1
  export type { EffectiveRetentionPolicy, ResolveForTenantArgs, ResolveRetentionPolicyArgs, ResolveTenantPresetArgs, RetentionOverride, RetentionPreset, RetentionPresetKey, } from "./feature.ts";
2
2
  export { createDataRetentionFeature, RETENTION_PRESETS, resolveRetentionPolicy, resolveRetentionPolicyForTenant, resolveTenantRetentionPreset, retentionOverrideSchema, SELECTABLE_RETENTION_PRESETS, tenantRetentionOverrideEntity, tenantRetentionOverrideTable, } from "./feature.ts";
3
3
  export { computeCutoff, type Instant } from "./keep-for.ts";
4
+ export { type RetentionCleanupSkip, type RunRetentionCleanupArgs, type RunRetentionCleanupResult, runRetentionCleanup, } from "./run-retention-cleanup.ts";
@@ -1,2 +1,3 @@
1
1
  export { createDataRetentionFeature, RETENTION_PRESETS, resolveRetentionPolicy, resolveRetentionPolicyForTenant, resolveTenantRetentionPreset, retentionOverrideSchema, SELECTABLE_RETENTION_PRESETS, tenantRetentionOverrideEntity, tenantRetentionOverrideTable, } from "./feature.js";
2
2
  export { computeCutoff } from "./keep-for.js";
3
+ export { runRetentionCleanup, } from "./run-retention-cleanup.js";
@@ -43,7 +43,7 @@ import { deleteFileRefBytes } from "@cosmicdrift/kumiko-framework/derivatives";
43
43
  import { createSystemUser, entityHasAnonymizableSubjectField, } from "@cosmicdrift/kumiko-framework/engine";
44
44
  import { fileRefEntity, fileRefsTable, } from "@cosmicdrift/kumiko-framework/files";
45
45
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
46
- import { runInSubTransaction } from "../shared/index.js";
46
+ import { collectSingleFileHolders, planFileFields, purgeRowFileRefs, runInSubTransaction, } from "../shared/index.js";
47
47
  import { computeCutoff } from "./keep-for.js";
48
48
  import { resolveRetentionPolicyForTenant } from "./resolve-for-tenant.js";
49
49
  import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override.js";
@@ -152,106 +152,7 @@ async function forgetRowAndEraseKeys(args) {
152
152
  return { isSuccess: true };
153
153
  });
154
154
  }
155
- const NO_FILE_FIELDS = { singleFields: [], multiFields: [] };
156
- // file/image = single-column fields (fileRef-id lives directly in the entity
157
- // row); files/images = plural, resolved via file_refs. Same set
158
- // as boot-validator/entity-handler.ts FILE_FIELD_TYPES and engine's
159
- // isFileField — inlined here rather than importing across the package
160
- // barrel since this module already needs the single/multi split, not just
161
- // membership.
162
- function planFileFields(fields) {
163
- const singleFields = [];
164
- const multiFields = [];
165
- for (const [name, field] of Object.entries(fields)) {
166
- const type = field.type; // @cast-boundary schema-walk
167
- if (type === "file" || type === "image")
168
- singleFields.push(name);
169
- else if (type === "files" || type === "images")
170
- multiFields.push(name);
171
- }
172
- return singleFields.length > 0 || multiFields.length > 0
173
- ? { singleFields, multiFields }
174
- : NO_FILE_FIELDS;
175
- }
176
- // Every fileRef-id a row's file/image/files/images-fields reference: single
177
- // fields hold the id in their own column, plural fields resolve
178
- // via file_refs (tenantId, entityType, entityId, fieldName).
179
- async function collectFileRefIds(db, tenantId, entityName, row, plan) {
180
- const ids = new Set();
181
- for (const field of plan.singleFields) {
182
- const value = row[field];
183
- if (typeof value === "string" && value.length > 0)
184
- ids.add(value);
185
- }
186
- if (plan.multiFields.length > 0) {
187
- const refRows = await selectMany(db, fileRefsTable, {
188
- tenantId,
189
- entityType: entityName,
190
- entityId: String(row["id"]),
191
- fieldName: { in: plan.multiFields },
192
- });
193
- for (const r of refRows)
194
- ids.add(r.id);
195
- }
196
- return Array.from(ids);
197
- }
198
- // A fileRef is bound to a DIFFERENT entity/record than the row currently
199
- // being purged — its binding (entityType/entityId), not the row's own
200
- // single-field column, decides ownership. A single-field value can point at
201
- // a fileRef uploaded/attached elsewhere; that fileRef's bytes belong to
202
- // whatever it's actually bound to, not to this row.
203
- function isFileRefBoundElsewhere(fileRef, entityName, rowId) {
204
- const entityType = fileRef["entityType"];
205
- const entityId = fileRef["entityId"];
206
- if (typeof entityType === "string" && entityType !== entityName)
207
- return true;
208
- if (typeof entityId === "string" && entityId !== rowId)
209
- return true;
210
- return false;
211
- }
212
- // Every implicit entity table with file/image fields: an unbound fileRef
213
- // (POST /files without entityType/entityId) can sit in single fields of
214
- // several entity types, so the sharing check must look beyond the purged
215
- // row's own table.
216
- function collectSingleFileHolders(registry) {
217
- const holders = [];
218
- for (const proj of registry.getAllProjections().values()) {
219
- if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table)
220
- continue;
221
- const entity = registry.getEntity(proj.source);
222
- if (!entity)
223
- continue;
224
- const { singleFields } = planFileFields(entity.fields);
225
- if (singleFields.length === 0)
226
- continue;
227
- holders.push({
228
- table: proj.table,
229
- tableHasTenantId: propertyOf(proj.table, "tenantId") !== undefined,
230
- singleFields,
231
- });
232
- }
233
- return holders;
234
- }
235
- // A single-field fileRef-id can be shared by more than one row, in the SAME
236
- // table (e.g. a stock-photo re-picked across records) or in another entity's
237
- // table. Purging its bytes would corrupt the row that isn't being deleted.
238
- // Bounded by batchLimit rows per run and holderFields queries per fileRef —
239
- // the same per-row event-cost tradeoff as the rest of this cron (see header).
240
- async function isFileRefSharedByOtherRow(db, holders, ownTable, tenantId, rowId, fileRefId) {
241
- for (const holder of holders) {
242
- for (const field of holder.singleFields) {
243
- const where = { [field]: fileRefId };
244
- if (holder.table === ownTable)
245
- where["id"] = { ne: rowId };
246
- if (holder.tableHasTenantId)
247
- where["tenantId"] = tenantId;
248
- const others = await selectMany(db, holder.table, where, { limit: 1 });
249
- if (others.length > 0)
250
- return true;
251
- }
252
- }
253
- return false;
254
- }
155
+ // --- hardDelete + file-fields (kumiko-framework#3089) ---
255
156
  // Throws (unsafe key, missing list permission) must fail this row closed,
256
157
  // not abort the whole tenant run.
257
158
  async function deleteFileFailingClosed(key, files, logContext) {
@@ -265,14 +166,32 @@ async function deleteFileFailingClosed(key, files, logContext) {
265
166
  }
266
167
  }
267
168
  // hardDelete for ONE row of an entity that has file/image/files/images
268
- // fields: delete the un-shared fileRefs' bytes (+ derivatives) first, then
269
- // their fileRef rows, then the entity row itself — bytes before rows so a
270
- // crash mid-way leaves an orphaned fileRef pointing at already-gone bytes
271
- // (safe: a later run's provider.delete is idempotent) rather than a gone
272
- // fileRef whose bytes leak forever.
169
+ // fields: purge the un-shared fileRefs (bytes + derivatives, then the fileRef
170
+ // rows), then the entity row itself.
273
171
  async function purgeHardDeleteRowWithFiles(args) {
274
172
  const rowId = String(args.row["id"]);
275
- const forgetEntityRow = () => forgetRowAndEraseKeys({
173
+ const logContext = `tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`;
174
+ const files = args.files;
175
+ const outcome = await purgeRowFileRefs({
176
+ db: args.db,
177
+ tenantId: args.tenantId,
178
+ entityName: args.entityName,
179
+ entity: args.entity,
180
+ table: args.table,
181
+ fileHolders: args.fileHolders,
182
+ row: args.row,
183
+ deleteBytes: files ? (key) => deleteFileFailingClosed(key, files, logContext) : undefined,
184
+ fileRefForget: async (fileRefId) => (await args.fileRefExecutor.forget({ id: fileRefId }, args.systemUser, args.tdb)).isSuccess,
185
+ onBytesDeleteFailed: (failedKeys) => {
186
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
187
+ console.warn(`[data-retention:hardDelete] ${logContext} storage delete failed keys=${failedKeys.join(",")}`);
188
+ },
189
+ });
190
+ if (outcome !== "purged") {
191
+ args.onSkip(outcome);
192
+ return false;
193
+ }
194
+ return (await forgetRowAndEraseKeys({
276
195
  db: args.db,
277
196
  executor: args.entityExecutor,
278
197
  entity: args.entity,
@@ -282,56 +201,7 @@ async function purgeHardDeleteRowWithFiles(args) {
282
201
  row: args.row,
283
202
  kms: args.kms,
284
203
  registry: args.registry,
285
- });
286
- const fileRefIds = await collectFileRefIds(args.db, args.tenantId, args.entityName, args.row, args.plan);
287
- if (fileRefIds.length === 0) {
288
- return (await forgetEntityRow()).isSuccess;
289
- }
290
- const fileRefRows = await selectMany(args.db, fileRefsTable, {
291
- id: { in: fileRefIds },
292
- tenantId: args.tenantId,
293
- });
294
- const toDelete = [];
295
- for (const fileRef of fileRefRows) {
296
- if (isFileRefBoundElsewhere(fileRef, args.entityName, rowId))
297
- continue;
298
- const shared = await isFileRefSharedByOtherRow(args.db, args.fileHolders, args.table, args.tenantId, rowId, String(fileRef["id"]));
299
- if (shared)
300
- continue;
301
- toDelete.push(fileRef);
302
- }
303
- if (toDelete.length === 0) {
304
- return (await forgetEntityRow()).isSuccess;
305
- }
306
- if (!args.files) {
307
- args.onSkip("missing_file_storage");
308
- return false;
309
- }
310
- const files = args.files;
311
- let anyFailed = false;
312
- for (const fileRef of toDelete) {
313
- const key = fileRef["storageKey"];
314
- if (typeof key !== "string" || key.length === 0)
315
- continue;
316
- const failedKeys = await deleteFileFailingClosed(key, files, `tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`);
317
- if (failedKeys.length > 0) {
318
- anyFailed = true;
319
- // biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
320
- console.warn(`[data-retention:hardDelete] tenant=${args.tenantId} entity=${args.entityName} row=${rowId} storage delete failed keys=${failedKeys.join(",")}`);
321
- }
322
- }
323
- if (anyFailed) {
324
- args.onSkip("file_delete_failed");
325
- return false;
326
- }
327
- for (const fileRef of toDelete) {
328
- const res = await args.fileRefExecutor.forget({ id: fileRef["id"] }, args.systemUser, args.tdb);
329
- if (!res.isSuccess) {
330
- args.onSkip("file_delete_failed");
331
- return false;
332
- }
333
- }
334
- return (await forgetEntityRow()).isSuccess;
204
+ })).isSuccess;
335
205
  }
336
206
  // hardDelete page for an entity WITH file/image/files/images fields — loads
337
207
  // full rows (not just id) so file-field values are available, and reports
@@ -369,7 +239,6 @@ async function purgeHardDeleteRowsWithFiles(args) {
369
239
  entityName: args.entityName,
370
240
  entity: args.entity,
371
241
  row,
372
- plan: args.plan,
373
242
  files: args.files,
374
243
  entityExecutor: args.entityExecutor,
375
244
  fileRefExecutor: args.fileRefExecutor,
@@ -520,7 +389,6 @@ export async function runRetentionCleanup(args) {
520
389
  tenantId,
521
390
  entityName,
522
391
  entity,
523
- plan: filePlan,
524
392
  files: args.files,
525
393
  entityExecutor: executor,
526
394
  fileRefExecutor,
@@ -1,5 +1,5 @@
1
1
  export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
2
- entityName: {
2
+ readonly entityName: {
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
@@ -22,7 +22,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
22
22
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
23
23
  required: true;
24
24
  };
25
- config: {
25
+ readonly config: {
26
26
  readonly type: "longText";
27
27
  readonly description?: string;
28
28
  readonly maxLength?: number;
@@ -38,7 +38,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
38
38
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
39
39
  required: true;
40
40
  };
41
- reason: {
41
+ readonly reason: {
42
42
  readonly type: "text";
43
43
  readonly description?: string;
44
44
  readonly maxLength?: number;
@@ -63,7 +63,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
63
63
  };
64
64
  }, "tenant">;
65
65
  export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-framework/db").EntityTable<import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
66
- entityName: {
66
+ readonly entityName: {
67
67
  readonly type: "text";
68
68
  readonly description?: string;
69
69
  readonly maxLength?: number;
@@ -86,7 +86,7 @@ export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-f
86
86
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
87
87
  required: true;
88
88
  };
89
- config: {
89
+ readonly config: {
90
90
  readonly type: "longText";
91
91
  readonly description?: string;
92
92
  readonly maxLength?: number;
@@ -102,7 +102,7 @@ export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-f
102
102
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
103
103
  required: true;
104
104
  };
105
- reason: {
105
+ readonly reason: {
106
106
  readonly type: "text";
107
107
  readonly description?: string;
108
108
  readonly maxLength?: number;
@@ -3,7 +3,7 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { ConfigAccessor, EscapeHatchAuditSink, NotifyJobDispatcher, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
5
5
  import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
6
- import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
6
+ import type { EnvelopeCipher, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
7
7
  import type { Redis } from "ioredis";
8
8
  import { type DeliveryChannel, type DeliveryService } from "./types.ts";
9
9
  import { type UnsubscribeLinkConfig } from "./unsubscribe-link.ts";
@@ -25,6 +25,7 @@ export type DeliveryServiceOptions = {
25
25
  readonly idempotencyRedis?: Redis;
26
26
  readonly jobRunner?: NotifyJobDispatcher;
27
27
  readonly secrets?: SecretsContext;
28
+ readonly addressCipher?: EnvelopeCipher;
28
29
  readonly filesForTenant?: (tenantId: string) => FileContext | undefined;
29
30
  readonly tenantConfigFor?: (tenantId: TenantId) => ConfigAccessor;
30
31
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
@@ -12,6 +12,7 @@ import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryIdempotencyRedisKey,
12
12
  import { addressOptOutKey, selectOptedOutAddresses, } from "./db/queries/address-opt-outs.js";
13
13
  import { selectNotificationPreferences } from "./db/queries/preferences.js";
14
14
  import { notificationTypeNames } from "./notification-type-aliases.js";
15
+ import { sealRecipientAddress } from "./recipient-address-seal.js";
15
16
  import { isDeliveryChannelPlugin, } from "./types.js";
16
17
  import { buildUnsubscribeUrl, unsubscribeLinkOrigin, } from "./unsubscribe-link.js";
17
18
  export function redactedMessageOf(err) {
@@ -28,7 +29,7 @@ export function collectChannels(registry) {
28
29
  });
29
30
  }
30
31
  export function createDeliveryService(options) {
31
- const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, filesForTenant, tenantConfigFor, escapeHatchAuditSink, log, unsubscribeLinks, } = options;
32
+ const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, addressCipher, filesForTenant, tenantConfigFor, escapeHatchAuditSink, log, unsubscribeLinks, } = options;
32
33
  const idemRedis = idempotencyRedis ?? rateLimit?.redis;
33
34
  const logError = createFallbackLogger("delivery", log);
34
35
  // Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
@@ -219,7 +220,7 @@ export function createDeliveryService(options) {
219
220
  await appendAttemptEvent(run.db, registry, deliveryAttemptId, queuedEntry);
220
221
  await run.jobDispatcher.dispatch(channel.render ? DeliveryJobs.render : DeliveryJobs.send, {
221
222
  channelName: channel.name,
222
- address,
223
+ ...(await sealRecipientAddress(address, tenantId, addressCipher)),
223
224
  tenantId,
224
225
  recipientId,
225
226
  notificationType,
@@ -20,8 +20,8 @@ export const unsubscribePageChromeQuery = defineQueryHandler({
20
20
  name: "unsubscribePageChrome",
21
21
  schema: z.object({ locale: z.string().min(2).max(35) }),
22
22
  access: { roles: access.systemAdmin },
23
- // Only reachable via createUnsubscribeRoutes' dispatchSystemQuery once verify()
24
- // proved the token signature.
23
+ // Only reachable via a system dispatch: the signed unsubscribe routes after verify(),
24
+ // or createUnsubscribePageChromeReader for a tenant the app resolved server-side.
25
25
  agent: { expose: false },
26
26
  description: "Returns the tenant's mail brand row and footer resolved for one locale, or null without a mail renderer; dispatched by the signed unsubscribe route to dress its pages like the mail, not meant for UI callers.",
27
27
  handler: async (query, ctx) => {
@@ -15,6 +15,6 @@ export { createDeliveryNotifyFactory } from "./notify-factory.ts";
15
15
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.ts";
16
16
  export { type CreateDeliveryTestContextOptions, createDeliveryTestContext } from "./testing.ts";
17
17
  export { type AttachmentOrigin, type ChannelContext, type ChannelMessage, type ChannelResult, type DeliveryChannel, type DeliveryChannelMode, type DeliveryChannelPlugin, type DeliveryLogEntry, type DeliveryService, isDeliveryChannelPlugin, type RenderedMessage, } from "./types.ts";
18
- export { type AddressUnsubscribeTokenPayload, createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, renderUnsubscribeResultPage, signAddressUnsubscribeToken, signUnsubscribeToken, type UnsubscribeResultPage, type UnsubscribeRouteOptions, type UnsubscribeTokenPayload, } from "./unsubscribe.ts";
18
+ export { type AddressUnsubscribeTokenPayload, createUnsubscribePageChromeReader, createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, renderUnsubscribeResultPage, signAddressUnsubscribeToken, signUnsubscribeToken, type UnsubscribePageChromeReader, type UnsubscribeResultPage, type UnsubscribeRouteOptions, type UnsubscribeTokenPayload, } from "./unsubscribe.ts";
19
19
  export type { UnsubscribeLinkConfig } from "./unsubscribe-link.ts";
20
20
  export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.ts";
@@ -14,5 +14,5 @@ export { createDeliveryNotifyFactory } from "./notify-factory.js";
14
14
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
15
15
  export { createDeliveryTestContext } from "./testing.js";
16
16
  export { isDeliveryChannelPlugin, } from "./types.js";
17
- export { createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, renderUnsubscribeResultPage, signAddressUnsubscribeToken, signUnsubscribeToken, } from "./unsubscribe.js";
17
+ export { createUnsubscribePageChromeReader, createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, renderUnsubscribeResultPage, signAddressUnsubscribeToken, signUnsubscribeToken, } from "./unsubscribe.js";
18
18
  export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
@@ -12,6 +12,7 @@ import { appendAttemptEvent } from "./attempt-log.js";
12
12
  import { buildChannelContext } from "./channel-context.js";
13
13
  import { DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank } from "./constants.js";
14
14
  import { collectChannels, redactedMessageOf } from "./delivery-service.js";
15
+ import { openRecipientAddress, recipientAddressFieldSchema, } from "./recipient-address-seal.js";
15
16
  const channelMessageSchema = z.object({
16
17
  notificationType: z.string(),
17
18
  title: z.string(),
@@ -19,9 +20,8 @@ const channelMessageSchema = z.object({
19
20
  data: z.record(z.string(), z.unknown()).optional(),
20
21
  locale: z.string().optional(),
21
22
  });
22
- const renderJobPayloadSchema = z.object({
23
+ const renderJobBaseSchema = z.object({
23
24
  channelName: z.string(),
24
- address: z.string(),
25
25
  tenantId: z.string(),
26
26
  recipientId: z.string().nullable(),
27
27
  notificationType: z.string(),
@@ -30,7 +30,9 @@ const renderJobPayloadSchema = z.object({
30
30
  message: channelMessageSchema,
31
31
  idempotencyKey: z.string().optional(),
32
32
  });
33
- const sendJobPayloadSchema = renderJobPayloadSchema.extend({
33
+ const renderJobPayloadSchema = renderJobBaseSchema.and(recipientAddressFieldSchema);
34
+ const sendJobPayloadSchema = renderJobBaseSchema
35
+ .extend({
34
36
  rendered: z
35
37
  .object({
36
38
  html: z.string(),
@@ -41,7 +43,8 @@ const sendJobPayloadSchema = renderJobPayloadSchema.extend({
41
43
  attachments: z.array(emailAttachmentRefSchema).max(MAX_EMAIL_ATTACHMENTS).optional(),
42
44
  })
43
45
  .optional(),
44
- });
46
+ })
47
+ .and(recipientAddressFieldSchema);
45
48
  function requireTenantScopedDeps(ctx, tenantId) {
46
49
  const registry = ctx.registry;
47
50
  if (!registry)
@@ -141,6 +144,16 @@ async function releasingKeyOnTerminalFailure(ctx, p, run) {
141
144
  throw err;
142
145
  }
143
146
  }
147
+ // A missing or rotated key must not hide the original failure, so the log row just omits the address.
148
+ async function addressForAttemptLog(p, ctx) {
149
+ try {
150
+ return await openRecipientAddress(p, p.tenantId, // @cast-boundary engine-payload — job payload string is the stream tenant
151
+ ctx.configEncryption);
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
144
157
  // The job runner stores err.message in the run row, and BullMQ keeps failedReason and
145
158
  // the stack trace in Redis in plain text, so the rethrown error carries only the code.
146
159
  // The redacted message goes to the log.
@@ -150,7 +163,7 @@ async function failAttempt(ctx, db, registry, p, code, err) {
150
163
  notificationType: p.notificationType,
151
164
  channel: p.channelName,
152
165
  });
153
- await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, "failed", code, p.address));
166
+ await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, "failed", code, await addressForAttemptLog(p, ctx)));
154
167
  throw new Error(code);
155
168
  }
156
169
  // Render the message and hand off to delivery.send. On failure: record the
@@ -196,8 +209,9 @@ async function sendStep(p, ctx) {
196
209
  const channel = resolveChannel(registry, p.channelName);
197
210
  const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets, ctx.config, (fileTenantId) => fileContextForTenant(ctx, ctx.systemUser.tenantId, fileTenantId));
198
211
  try {
199
- const result = await channel.send(p.address, toMessage(p), channelCtx, p.rendered);
200
- await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, result.status, result.error ?? null, result.address ?? p.address, result.confirmed));
212
+ const address = await openRecipientAddress(p, tenantId, ctx.configEncryption);
213
+ const result = await channel.send(address, toMessage(p), channelCtx, p.rendered);
214
+ await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, result.status, result.error ?? null, result.address ?? address, result.confirmed));
201
215
  // A soft failure ends the job successfully, so no retry follows and the caller's
202
216
  // retry must not be skipped as a duplicate.
203
217
  if (result.status === "failed")
@@ -0,0 +1,13 @@
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
3
+ import * as z from "zod";
4
+ export declare const recipientAddressFieldSchema: z.ZodUnion<readonly [z.ZodObject<{
5
+ address: z.ZodString;
6
+ sealedAddress: z.ZodOptional<z.ZodUndefined>;
7
+ }, z.core.$strip>, z.ZodObject<{
8
+ sealedAddress: z.ZodString;
9
+ address: z.ZodOptional<z.ZodUndefined>;
10
+ }, z.core.$strip>]>;
11
+ export type RecipientAddressField = z.infer<typeof recipientAddressFieldSchema>;
12
+ export declare function sealRecipientAddress(address: string, tenantId: TenantId, cipher: EnvelopeCipher | undefined): Promise<RecipientAddressField>;
13
+ export declare function openRecipientAddress(payload: RecipientAddressField, tenantId: TenantId, cipher: EnvelopeCipher | undefined): Promise<string>;
@@ -0,0 +1,53 @@
1
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
2
+ import * as z from "zod";
3
+ // Queued delivery jobs sit in Redis and the job-run table, so the recipient address travels
4
+ // sealed under the app master key. Without a master key it stays plaintext (`address`); jobs
5
+ // queued before sealing existed carry `address` too and keep working.
6
+ // Exactly one of the two fields: both or neither fails the payload parse.
7
+ export const recipientAddressFieldSchema = z.union([
8
+ z.object({ address: z.string(), sealedAddress: z.undefined().optional() }),
9
+ z.object({ sealedAddress: z.string(), address: z.undefined().optional() }),
10
+ ]);
11
+ // The cipher is shared with config values and PII entity fields and has no AAD, so the purpose is
12
+ // bound inside the authenticated plaintext. Without it a foreign ciphertext of the same tenant
13
+ // placed into sealedAddress would be decrypted and its plaintext sent to the channel. The tenant
14
+ // is bound there too: the env key provider ignores the encrypt scope, so a sealedAddress of tenant
15
+ // B would otherwise open inside a job of tenant A.
16
+ const RECIPIENT_ADDRESS_PURPOSE = "delivery.recipientAddress";
17
+ const sealedRecipientAddressSchema = z.object({
18
+ purpose: z.literal(RECIPIENT_ADDRESS_PURPOSE),
19
+ tenantId: z.string(),
20
+ address: z.string(),
21
+ });
22
+ export async function sealRecipientAddress(address, tenantId, cipher) {
23
+ if (!cipher)
24
+ return { address };
25
+ const plaintext = JSON.stringify({ purpose: RECIPIENT_ADDRESS_PURPOSE, tenantId, address });
26
+ return { sealedAddress: await cipher.encrypt(plaintext, { tenantId }) };
27
+ }
28
+ function parseSealedRecipientAddress(plaintext, tenantId) {
29
+ let decoded;
30
+ try {
31
+ decoded = JSON.parse(plaintext);
32
+ }
33
+ catch {
34
+ decoded = undefined;
35
+ }
36
+ const parsed = sealedRecipientAddressSchema.safeParse(decoded);
37
+ if (!parsed.success || parsed.data.tenantId !== tenantId) {
38
+ throw new InternalError({
39
+ message: "delivery job: sealedAddress was not sealed as a delivery recipient address",
40
+ });
41
+ }
42
+ return parsed.data.address;
43
+ }
44
+ export async function openRecipientAddress(payload, tenantId, cipher) {
45
+ if (payload.sealedAddress === undefined)
46
+ return payload.address;
47
+ if (!cipher) {
48
+ throw new InternalError({
49
+ message: "delivery job: payload carries a sealed recipient address but this process has no master key (configEncryption); run the worker with the same KUMIKO_SECRETS_MASTER_KEY_V<n> as the web process",
50
+ });
51
+ }
52
+ return parseSealedRecipientAddress(await cipher.decrypt(payload.sealedAddress, { tenantId }), tenantId);
53
+ }
@@ -2,7 +2,7 @@ import { type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
2
  export declare const deliveryAttemptsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
3
3
  export declare const deliveryAttemptsTableMeta: EntityTableMeta;
4
4
  export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
5
- userId: {
5
+ readonly userId: {
6
6
  readonly type: "text";
7
7
  readonly description?: string;
8
8
  readonly maxLength?: number;
@@ -25,7 +25,7 @@ export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-t
25
25
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
26
26
  required: true;
27
27
  };
28
- notificationType: {
28
+ readonly notificationType: {
29
29
  readonly type: "text";
30
30
  readonly description?: string;
31
31
  readonly maxLength?: number;
@@ -48,7 +48,7 @@ export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-t
48
48
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
49
49
  required: true;
50
50
  };
51
- channel: {
51
+ readonly channel: {
52
52
  readonly type: "text";
53
53
  readonly description?: string;
54
54
  readonly maxLength?: number;
@@ -71,13 +71,14 @@ export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-t
71
71
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
72
72
  required: true;
73
73
  };
74
- enabled: import("@cosmicdrift/kumiko-types/fields").BooleanFieldDef & {
74
+ readonly enabled: import("@cosmicdrift/kumiko-types/fields").BooleanFieldDef & {
75
75
  required: false;
76
+ default: boolean;
76
77
  };
77
78
  }, "tenant">;
78
79
  export declare const notificationPreferencesTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
79
80
  export declare const notificationAddressOptOutEntity: import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
80
- addressHash: {
81
+ readonly addressHash: {
81
82
  readonly type: "text";
82
83
  readonly description?: string;
83
84
  readonly maxLength?: number;
@@ -100,7 +101,7 @@ export declare const notificationAddressOptOutEntity: import("@cosmicdrift/kumik
100
101
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
101
102
  required: true;
102
103
  };
103
- notificationType: {
104
+ readonly notificationType: {
104
105
  readonly type: "text";
105
106
  readonly description?: string;
106
107
  readonly maxLength?: number;
@@ -123,7 +124,7 @@ export declare const notificationAddressOptOutEntity: import("@cosmicdrift/kumik
123
124
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
124
125
  required: true;
125
126
  };
126
- channel: {
127
+ readonly channel: {
127
128
  readonly type: "text";
128
129
  readonly description?: string;
129
130
  readonly maxLength?: number;
@@ -4,7 +4,7 @@ import type { ConfigAccessor, EscapeHatchAuditSink, Registry, TenantId } from "@
4
4
  import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
5
5
  import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
6
6
  import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
7
- import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
7
+ import type { EnvelopeCipher, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
8
8
  import type { Redis } from "ioredis";
9
9
  import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.ts";
10
10
  import type { DeliveryService } from "./types.ts";
@@ -17,6 +17,7 @@ export type CreateDeliveryTestContextOptions = {
17
17
  readonly log?: Logger;
18
18
  readonly jobRunner?: JobRunner;
19
19
  readonly secrets?: SecretsContext;
20
+ readonly addressCipher?: EnvelopeCipher;
20
21
  readonly idempotencyRedis?: Redis;
21
22
  readonly filesForTenant?: (tenantId: string) => FileContext | undefined;
22
23
  readonly tenantConfigFor?: (tenantId: TenantId) => ConfigAccessor;
@@ -23,5 +23,6 @@ export function createDeliveryTestContext(deps, options = {}) {
23
23
  return {
24
24
  deliveryService, // exposed so tests can inspect/call directly if needed
25
25
  _notifyFactory: createDeliveryNotifyFactory(deliveryService),
26
+ ...(options.addressCipher && { configEncryption: options.addressCipher }),
26
27
  };
27
28
  }
@@ -1,4 +1,4 @@
1
- import { type ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
1
+ import { type ExtraRouteDefinition, type SystemDispatchArgs } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import type { MailChrome } from "../renderer-foundation/index.ts";
4
4
  export type UnsubscribeTokenPayload = {
@@ -46,7 +46,9 @@ export type UnsubscribeResultPage = {
46
46
  };
47
47
  /** The result page the unsubscribe routes answer with, for apps serving their own
48
48
  * (e.g. legacy) unsubscribe links. Texts come from the mail translations of `locale`;
49
- * `chrome` (the mail renderer's readMailChrome) adds the tenant's brand row and footer.
49
+ * `chrome` adds the tenant's brand row and footer: read it with
50
+ * createUnsubscribePageChromeReader for a tenant the server resolved itself (e.g. by
51
+ * Host), never for a tenantId taken from the query string or body.
50
52
  * Served under runProdApp's default CSP the style block and the same-origin font pass;
51
53
  * an https logo additionally needs its origin, e.g.
52
54
  * `securityHeaders: { csp: { extend: { "img-src": [logoOrigin] } } }`. A hand-written CSP
@@ -55,4 +57,13 @@ export declare function renderUnsubscribeResultPage(page: UnsubscribeResultPage
55
57
  readonly locale?: string | undefined;
56
58
  readonly chrome?: MailChrome | null | undefined;
57
59
  }): string;
60
+ type SystemQueryDispatch = {
61
+ readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
62
+ };
63
+ export type UnsubscribePageChromeReader = (tenantId: TenantId, locale: string | undefined) => Promise<MailChrome | null>;
64
+ /** Reads the chrome for renderUnsubscribeResultPage in an app route without
65
+ * public access: build it once in the `wire` hook from its dispatchSystemQuery.
66
+ * Null without a mail renderer or when the read fails. */
67
+ export declare function createUnsubscribePageChromeReader(deps: SystemQueryDispatch): UnsubscribePageChromeReader;
58
68
  export declare function createUnsubscribeRoutes(options: UnsubscribeRouteOptions): readonly ExtraRouteDefinition[];
69
+ export {};