@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.
- package/dist/auth-mfa/db/queries.d.ts +3 -2
- package/dist/auth-mfa/db/queries.js +2 -5
- package/dist/auth-mfa/schema/user-mfa.d.ts +10 -10
- package/dist/billing-foundation/entities.d.ts +10 -10
- package/dist/billing-foundation/projection.d.ts +10 -10
- package/dist/cap-counter/entity.d.ts +4 -4
- package/dist/cap-overview/web/cap-cards-panel.js +2 -1
- package/dist/compliance-profiles/schema/profile-selection.d.ts +4 -4
- package/dist/config/table.d.ts +6 -6
- package/dist/config/web/index.d.ts +1 -0
- package/dist/config/web/index.js +1 -0
- package/dist/crypto-shredding/handlers/forget-subject.write.js +55 -30
- package/dist/crypto-shredding/resolve-file-bytes-deleter.d.ts +3 -0
- package/dist/crypto-shredding/resolve-file-bytes-deleter.js +24 -0
- package/dist/custom-fields/entity.d.ts +9 -7
- package/dist/custom-fields/executor.d.ts +19 -1
- package/dist/data-retention/index.d.ts +1 -0
- package/dist/data-retention/index.js +1 -0
- package/dist/data-retention/run-retention-cleanup.js +27 -159
- package/dist/data-retention/schema/tenant-retention-override.d.ts +6 -6
- package/dist/delivery/delivery-service.d.ts +2 -1
- package/dist/delivery/delivery-service.js +3 -2
- package/dist/delivery/handlers/unsubscribe-page-chrome.query.js +2 -2
- package/dist/delivery/index.d.ts +1 -1
- package/dist/delivery/index.js +1 -1
- package/dist/delivery/jobs.js +21 -7
- package/dist/delivery/recipient-address-seal.d.ts +13 -0
- package/dist/delivery/recipient-address-seal.js +53 -0
- package/dist/delivery/tables.d.ts +8 -7
- package/dist/delivery/testing.d.ts +2 -1
- package/dist/delivery/testing.js +1 -0
- package/dist/delivery/unsubscribe.d.ts +13 -2
- package/dist/delivery/unsubscribe.js +9 -1
- package/dist/document-ingest-foundation/entity.d.ts +8 -8
- package/dist/document-ingest-foundation/executor.d.ts +16 -1
- package/dist/file-foundation/read-file-ref-content.js +2 -2
- package/dist/folders/entity.d.ts +8 -8
- package/dist/folders/executor.d.ts +29 -2
- package/dist/form-draft/entity.d.ts +3 -3
- package/dist/form-draft/executor.d.ts +15 -1
- package/dist/inbound-mail-foundation/entities.d.ts +31 -31
- package/dist/inbound-mail-foundation/projection.d.ts +24 -24
- package/dist/jobs/job-run-table.d.ts +10 -10
- package/dist/ledger/entity.d.ts +21 -21
- package/dist/ledger/executor.d.ts +57 -3
- package/dist/managed-pages/table.d.ts +16 -14
- package/dist/notes-history/entity.d.ts +14 -14
- package/dist/notes-history/executor.d.ts +31 -2
- package/dist/personal-access-tokens/schema/api-token.d.ts +9 -9
- package/dist/personal-access-tokens/screens.js +14 -9
- package/dist/secrets/table.d.ts +2 -2
- package/dist/sessions/schema/user-session.d.ts +8 -8
- package/dist/shared/index.d.ts +2 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/purge-files-reaching-subject.d.ts +25 -0
- package/dist/shared/purge-files-reaching-subject.js +146 -0
- package/dist/shared/purge-row-file-refs.d.ts +33 -0
- package/dist/shared/purge-row-file-refs.js +152 -0
- package/dist/tags/entity.d.ts +9 -9
- package/dist/tags/executor.d.ts +30 -2
- package/dist/template-resolver/handlers/shared.d.ts +28 -1
- package/dist/template-resolver/table.d.ts +32 -32
- package/dist/template-resolver/template-preview.js +13 -4
- package/dist/template-resolver/user-content-table.d.ts +16 -16
- package/dist/template-resolver/web/client-plugin.js +2 -1
- package/dist/template-resolver/web/document-editor-extensions.d.ts +1 -1
- package/dist/template-resolver/web/document-editor-extensions.js +2 -5
- package/dist/template-resolver/web/placeholder-line-editor.js +2 -2
- package/dist/tenant/handlers/list.query.d.ts +21 -1
- package/dist/tenant/invitation-table.d.ts +14 -14
- package/dist/tenant/membership-table.d.ts +4 -4
- package/dist/tenant/schema/tenant.d.ts +20 -18
- package/dist/tier-engine/entity.d.ts +2 -2
- package/dist/tier-engine/tier-resolver.d.ts +2 -2
- package/dist/user/handlers/me.query.d.ts +21 -0
- package/dist/user/schema/user.d.ts +24 -22
- package/dist/user-data-rights/run-export-jobs.d.ts +20 -1
- package/dist/user-data-rights/run-forget-cleanup.js +35 -1
- package/dist/user-data-rights/schema/download-attempt.d.ts +16 -16
- package/dist/user-data-rights/schema/download-token.d.ts +16 -16
- package/dist/user-data-rights/schema/export-job.d.ts +20 -20
- package/dist/user-data-rights/web/privacy-center-screen.js +2 -1
- package/dist/waitlist/entity.d.ts +24 -24
- package/dist/waitlist/lib.d.ts +24 -1
- package/package.json +11 -11
- package/src/crypto-shredding/changes.json +6 -0
- package/src/data-retention/changes.json +6 -0
- package/src/delivery/changes.json +13 -0
- package/src/files/changes.json +7 -0
- package/src/personal-access-tokens/changes.json +12 -0
- package/src/template-resolver/changes.json +6 -0
- 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
|
-
|
|
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:
|
|
269
|
-
//
|
|
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
|
|
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
|
|
24
|
-
//
|
|
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) => {
|
package/dist/delivery/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/delivery/index.js
CHANGED
|
@@ -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";
|
package/dist/delivery/jobs.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
200
|
-
|
|
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;
|
package/dist/delivery/testing.js
CHANGED
|
@@ -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`
|
|
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 {};
|