@cosmicdrift/kumiko-bundled-features 0.312.0 → 0.313.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/package.json +9 -9
- package/src/__tests__/extension-points-typed.test.ts +4 -0
- package/src/agent-tools/__tests__/agent-manifest.test.ts +30 -0
- package/src/agent-tools/agent-manifest.ts +7 -1
- package/src/agent-tools/changes.json +8 -1
- package/src/audit/changes.json +6 -0
- package/src/audit/feature.ts +1 -2
- package/src/audit/i18n.ts +3 -0
- package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +424 -0
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +75 -5
- package/src/auth-email-password/handlers/signup-request.write.ts +60 -0
- package/src/auth-email-password/signup-token-store.ts +87 -1
- package/src/auth-email-password/web/auth-client.ts +10 -1
- package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +160 -0
- package/src/data-retention/changes.json +6 -0
- package/src/data-retention/run-retention-cleanup.ts +160 -18
- package/src/delivery/__tests__/delivery.integration.test.ts +206 -3
- package/src/delivery/address-opt-out.ts +87 -0
- package/src/delivery/changes.json +7 -0
- package/src/delivery/db/queries/address-opt-outs.ts +23 -0
- package/src/delivery/delivery-service.ts +32 -0
- package/src/delivery/feature.ts +9 -1
- package/src/delivery/index.ts +5 -0
- package/src/delivery/tables.ts +37 -0
- package/src/delivery/unsubscribe.ts +101 -16
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +40 -0
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +5 -0
- package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +23 -6
- package/src/shared/index.ts +10 -1
- package/src/shared/run-in-sub-transaction.ts +35 -0
- package/src/shared/signup-handover.ts +67 -0
- package/src/shared/single-use-token-store.ts +25 -5
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +44 -0
- package/src/subscription-stripe/changes.json +9 -1
- package/src/subscription-stripe/feature.ts +13 -1
- package/src/subscription-stripe/plugin-methods.ts +17 -2
- package/src/tenant/__tests__/is-tenant-serving-public-content.test.ts +21 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant/index.ts +1 -0
- package/src/tenant/is-tenant-serving-public-content.ts +13 -0
- package/src/tenant/web/__tests__/member-roles-cell.test.tsx +59 -0
- package/src/tenant/web/__tests__/member-status-cell.test.tsx +46 -0
- package/src/tenant/web/member-roles-cell.tsx +11 -2
- package/src/tenant/web/member-status-cell.tsx +6 -2
- package/src/tenant/web/translate-or-raw.ts +13 -0
- package/src/tenant-handover/__tests__/claim-storage-usage.integration.test.ts +201 -0
- package/src/tenant-handover/__tests__/claim.integration.test.ts +115 -2
- package/src/tenant-handover/__tests__/transfer-graph.test.ts +15 -0
- package/src/tenant-handover/changes.json +12 -0
- package/src/tenant-handover/events.ts +6 -0
- package/src/tenant-handover/feature.ts +11 -0
- package/src/tenant-handover/handlers/claim.write.ts +8 -25
- package/src/tenant-handover/move-entity-graph.ts +30 -1
- package/src/tenant-handover/root-anchor.ts +50 -0
- package/src/tenant-handover/signup-handover-provider.ts +62 -0
- package/src/tenant-handover/transfer-graph.ts +6 -2
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/feature.ts +1 -2
- package/src/user-data-rights/i18n.ts +3 -0
- package/src/user-data-rights/run-forget-cleanup.ts +1 -38
|
@@ -39,16 +39,27 @@
|
|
|
39
39
|
// einzelnen Page — sonst verstopfen erledigte Rows das batchLimit-Fenster.
|
|
40
40
|
|
|
41
41
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
42
|
+
import {
|
|
43
|
+
collectPiiSubjectFields,
|
|
44
|
+
configuredPiiSubjectKms,
|
|
45
|
+
isSelfPiiField,
|
|
46
|
+
type KmsAdapter,
|
|
47
|
+
resolveSubjectForField,
|
|
48
|
+
type SubjectId,
|
|
49
|
+
subjectIdToKey,
|
|
50
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
42
51
|
import {
|
|
43
52
|
createEventStoreExecutor,
|
|
44
53
|
createTenantDb,
|
|
45
54
|
type DbRunner,
|
|
55
|
+
nullBlindIndexesForSubject,
|
|
46
56
|
type TenantDb,
|
|
47
57
|
type WhereObject,
|
|
48
58
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
49
59
|
import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
50
60
|
import {
|
|
51
61
|
createSystemUser,
|
|
62
|
+
type EntityDefinition,
|
|
52
63
|
type EntityId,
|
|
53
64
|
type Registry,
|
|
54
65
|
type SessionUser,
|
|
@@ -59,6 +70,7 @@ import {
|
|
|
59
70
|
fileRefEntity,
|
|
60
71
|
fileRefsTable,
|
|
61
72
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
73
|
+
import { runInSubTransaction } from "../shared";
|
|
62
74
|
import { computeCutoff, type Instant } from "./keep-for";
|
|
63
75
|
import type { RetentionPresetKey } from "./presets";
|
|
64
76
|
import { resolveRetentionPolicyForTenant } from "./resolve-for-tenant";
|
|
@@ -92,6 +104,11 @@ export interface RunRetentionCleanupArgs {
|
|
|
92
104
|
* Missing while fileRefs are due → the row stays (skipped "missing_file_storage"),
|
|
93
105
|
* fail-closed instead of orphaning bytes. */
|
|
94
106
|
readonly files?: FileContext;
|
|
107
|
+
/** KMS for erasing a hardDeleted row's own subject key (Refs #2057). Defaults
|
|
108
|
+
* to configuredPiiSubjectKms() (same fallback as run-forget-cleanup.ts);
|
|
109
|
+
* undefined/no-KMS-configured skips erasure entirely — the row is still
|
|
110
|
+
* hard-deleted, only crypto-shredding of its DEK is not attempted. */
|
|
111
|
+
readonly kms?: KmsAdapter;
|
|
95
112
|
}
|
|
96
113
|
|
|
97
114
|
export interface RetentionCleanupSkip {
|
|
@@ -131,6 +148,110 @@ async function purgeMatchingRows(
|
|
|
131
148
|
return count;
|
|
132
149
|
}
|
|
133
150
|
|
|
151
|
+
// --- hardDelete crypto-shredding (Refs #2057) ---
|
|
152
|
+
//
|
|
153
|
+
// recordOwned/self-pii resolution (subject-resolver.ts) only ever reads
|
|
154
|
+
// `row.id` for these two kinds — resolveRecordSubject uses opts.entityName +
|
|
155
|
+
// row.id, resolveSelfPiiSubject uses row.id alone — so a `{ id }`-shaped row
|
|
156
|
+
// (purgeMatchingRows's hardDelete select) is enough; the with-files path's
|
|
157
|
+
// full row works the same way.
|
|
158
|
+
//
|
|
159
|
+
// userOwned/tenantOwned are explicitly excluded: hardDelete only destroys
|
|
160
|
+
// THIS row's own data, but the owning user's/tenant's key may still protect
|
|
161
|
+
// OTHER live rows that reference the same subject — erasing it here would
|
|
162
|
+
// be a silent cross-row data-loss bug, not a retention fix.
|
|
163
|
+
function collectHardDeleteEraseSubjects(
|
|
164
|
+
entity: EntityDefinition,
|
|
165
|
+
entityName: string,
|
|
166
|
+
tenantId: TenantId,
|
|
167
|
+
row: Record<string, unknown>,
|
|
168
|
+
): readonly SubjectId[] {
|
|
169
|
+
const subjectsByKey = new Map<string, SubjectId>();
|
|
170
|
+
for (const fieldName of collectPiiSubjectFields(entity)) {
|
|
171
|
+
const field = entity.fields[fieldName];
|
|
172
|
+
if (!field) continue;
|
|
173
|
+
const isRecordOwned = "recordOwned" in field && field.recordOwned === true;
|
|
174
|
+
const isUserOwned = "userOwned" in field && field.userOwned !== undefined;
|
|
175
|
+
const isTenantOwned = "tenantOwned" in field && field.tenantOwned === true;
|
|
176
|
+
if (isUserOwned || isTenantOwned) continue; // never erase — see header comment
|
|
177
|
+
if (!isRecordOwned && !isSelfPiiField(field)) continue;
|
|
178
|
+
const subject = resolveSubjectForField(entity, fieldName, row, { tenantId, entityName });
|
|
179
|
+
if (subject) subjectsByKey.set(subjectIdToKey(subject), subject);
|
|
180
|
+
}
|
|
181
|
+
return Array.from(subjectsByKey.values());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Erases the row's own recordOwned/self-pii subject key(s) + sweeps their
|
|
185
|
+
// blind indexes. Called from INSIDE the same sub-transaction as the row's forget
|
|
186
|
+
// (see forgetRowAndEraseKeys) — an eraseKey throw rolls the forget back too,
|
|
187
|
+
// same crash-safety contract as run-forget-cleanup.ts:477-503 (eraseKey is
|
|
188
|
+
// contractually idempotent per KmsAdapter, so a retried run re-erases safely).
|
|
189
|
+
async function eraseHardDeletedRowKeys(args: {
|
|
190
|
+
readonly kms: KmsAdapter | undefined;
|
|
191
|
+
readonly tx: DbRunner;
|
|
192
|
+
readonly registry: Registry;
|
|
193
|
+
readonly entity: EntityDefinition;
|
|
194
|
+
readonly entityName: string;
|
|
195
|
+
readonly tenantId: TenantId;
|
|
196
|
+
readonly row: Record<string, unknown>;
|
|
197
|
+
}): Promise<void> {
|
|
198
|
+
// skip: no KMS adapter, so there are no per-subject keys to erase
|
|
199
|
+
if (!args.kms) return;
|
|
200
|
+
const subjects = collectHardDeleteEraseSubjects(
|
|
201
|
+
args.entity,
|
|
202
|
+
args.entityName,
|
|
203
|
+
args.tenantId,
|
|
204
|
+
args.row,
|
|
205
|
+
);
|
|
206
|
+
for (const subject of subjects) {
|
|
207
|
+
await args.kms.eraseKey(subject, {
|
|
208
|
+
requestId: `data-retention:hardDelete:${args.entityName}:${String(args.row["id"])}`,
|
|
209
|
+
eraseReason: "data-retention:hardDelete",
|
|
210
|
+
});
|
|
211
|
+
await nullBlindIndexesForSubject(args.tx, args.registry.features, subjectIdToKey(subject));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Shared by both hardDelete paths (purgeMatchingRows's op below and
|
|
216
|
+
// purgeHardDeleteRowWithFiles) so the erase-after-forget ordering can't be
|
|
217
|
+
// forgotten on either one. Wraps the executor's own forget() (which already
|
|
218
|
+
// opens its own nested savepoints) in one sub-transaction that also covers
|
|
219
|
+
// eraseKey + the blind-index sweep — a KMS failure rolls the whole row back,
|
|
220
|
+
// not just the forget. The retention job hands in a pool connection, so this
|
|
221
|
+
// must open a real BEGIN there; a savepoint-if-supported helper would run
|
|
222
|
+
// unconfined and leave the row gone with its key still live.
|
|
223
|
+
async function forgetRowAndEraseKeys(args: {
|
|
224
|
+
readonly db: DbRunner;
|
|
225
|
+
readonly executor: ReturnType<typeof createEventStoreExecutor>;
|
|
226
|
+
readonly entity: EntityDefinition;
|
|
227
|
+
readonly entityName: string;
|
|
228
|
+
readonly tenantId: TenantId;
|
|
229
|
+
readonly systemUser: SessionUser;
|
|
230
|
+
readonly row: Record<string, unknown>;
|
|
231
|
+
readonly kms: KmsAdapter | undefined;
|
|
232
|
+
readonly registry: Registry;
|
|
233
|
+
}): Promise<{ readonly isSuccess: boolean }> {
|
|
234
|
+
return runInSubTransaction(args.db, async (sp) => {
|
|
235
|
+
const tdb = createTenantDb(sp, args.tenantId, "system");
|
|
236
|
+
const result = await args.executor.forget(
|
|
237
|
+
{ id: args.row["id"] as EntityId },
|
|
238
|
+
args.systemUser,
|
|
239
|
+
tdb,
|
|
240
|
+
);
|
|
241
|
+
if (!result.isSuccess) return { isSuccess: false };
|
|
242
|
+
await eraseHardDeletedRowKeys({
|
|
243
|
+
kms: args.kms,
|
|
244
|
+
tx: sp,
|
|
245
|
+
registry: args.registry,
|
|
246
|
+
entity: args.entity,
|
|
247
|
+
entityName: args.entityName,
|
|
248
|
+
tenantId: args.tenantId,
|
|
249
|
+
row: args.row,
|
|
250
|
+
});
|
|
251
|
+
return { isSuccess: true };
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
134
255
|
// --- hardDelete + file-fields (kumiko-framework#3089) ---
|
|
135
256
|
|
|
136
257
|
type FileFieldPlan = {
|
|
@@ -240,6 +361,7 @@ async function purgeHardDeleteRowWithFiles(args: {
|
|
|
240
361
|
readonly tableHasTenantId: boolean;
|
|
241
362
|
readonly tenantId: TenantId;
|
|
242
363
|
readonly entityName: string;
|
|
364
|
+
readonly entity: EntityDefinition;
|
|
243
365
|
readonly row: Record<string, unknown>;
|
|
244
366
|
readonly plan: FileFieldPlan;
|
|
245
367
|
readonly files: FileContext | undefined;
|
|
@@ -247,9 +369,23 @@ async function purgeHardDeleteRowWithFiles(args: {
|
|
|
247
369
|
readonly fileRefExecutor: ReturnType<typeof createEventStoreExecutor>;
|
|
248
370
|
readonly systemUser: SessionUser;
|
|
249
371
|
readonly tdb: TenantDb;
|
|
372
|
+
readonly kms: KmsAdapter | undefined;
|
|
373
|
+
readonly registry: Registry;
|
|
250
374
|
readonly onSkip: (reason: "missing_file_storage" | "file_delete_failed") => void;
|
|
251
375
|
}): Promise<boolean> {
|
|
252
376
|
const rowId = String(args.row["id"]);
|
|
377
|
+
const forgetEntityRow = () =>
|
|
378
|
+
forgetRowAndEraseKeys({
|
|
379
|
+
db: args.db,
|
|
380
|
+
executor: args.entityExecutor,
|
|
381
|
+
entity: args.entity,
|
|
382
|
+
entityName: args.entityName,
|
|
383
|
+
tenantId: args.tenantId,
|
|
384
|
+
systemUser: args.systemUser,
|
|
385
|
+
row: args.row,
|
|
386
|
+
kms: args.kms,
|
|
387
|
+
registry: args.registry,
|
|
388
|
+
});
|
|
253
389
|
const fileRefIds = await collectFileRefIds(
|
|
254
390
|
args.db,
|
|
255
391
|
args.tenantId,
|
|
@@ -258,13 +394,7 @@ async function purgeHardDeleteRowWithFiles(args: {
|
|
|
258
394
|
args.plan,
|
|
259
395
|
);
|
|
260
396
|
if (fileRefIds.length === 0) {
|
|
261
|
-
return (
|
|
262
|
-
await args.entityExecutor.forget(
|
|
263
|
-
{ id: args.row["id"] as EntityId },
|
|
264
|
-
args.systemUser,
|
|
265
|
-
args.tdb,
|
|
266
|
-
)
|
|
267
|
-
).isSuccess;
|
|
397
|
+
return (await forgetEntityRow()).isSuccess;
|
|
268
398
|
}
|
|
269
399
|
|
|
270
400
|
const fileRefRows = await selectMany<Record<string, unknown>>(args.db, fileRefsTable, {
|
|
@@ -289,13 +419,7 @@ async function purgeHardDeleteRowWithFiles(args: {
|
|
|
289
419
|
}
|
|
290
420
|
|
|
291
421
|
if (toDelete.length === 0) {
|
|
292
|
-
return (
|
|
293
|
-
await args.entityExecutor.forget(
|
|
294
|
-
{ id: args.row["id"] as EntityId },
|
|
295
|
-
args.systemUser,
|
|
296
|
-
args.tdb,
|
|
297
|
-
)
|
|
298
|
-
).isSuccess;
|
|
422
|
+
return (await forgetEntityRow()).isSuccess;
|
|
299
423
|
}
|
|
300
424
|
|
|
301
425
|
if (!args.files) {
|
|
@@ -339,9 +463,7 @@ async function purgeHardDeleteRowWithFiles(args: {
|
|
|
339
463
|
}
|
|
340
464
|
}
|
|
341
465
|
|
|
342
|
-
return (
|
|
343
|
-
await args.entityExecutor.forget({ id: args.row["id"] as EntityId }, args.systemUser, args.tdb)
|
|
344
|
-
).isSuccess;
|
|
466
|
+
return (await forgetEntityRow()).isSuccess;
|
|
345
467
|
}
|
|
346
468
|
|
|
347
469
|
// hardDelete page for an entity WITH file/image/files/images fields — loads
|
|
@@ -356,12 +478,15 @@ async function purgeHardDeleteRowsWithFiles(args: {
|
|
|
356
478
|
readonly batchLimit: number;
|
|
357
479
|
readonly tenantId: TenantId;
|
|
358
480
|
readonly entityName: string;
|
|
481
|
+
readonly entity: EntityDefinition;
|
|
359
482
|
readonly plan: FileFieldPlan;
|
|
360
483
|
readonly files: FileContext | undefined;
|
|
361
484
|
readonly entityExecutor: ReturnType<typeof createEventStoreExecutor>;
|
|
362
485
|
readonly fileRefExecutor: ReturnType<typeof createEventStoreExecutor>;
|
|
363
486
|
readonly systemUser: SessionUser;
|
|
364
487
|
readonly tdb: TenantDb;
|
|
488
|
+
readonly kms: KmsAdapter | undefined;
|
|
489
|
+
readonly registry: Registry;
|
|
365
490
|
readonly skipped: RetentionCleanupSkip[];
|
|
366
491
|
}): Promise<number> {
|
|
367
492
|
const rows = await selectMany<Record<string, unknown>>(args.db, args.table, args.where, {
|
|
@@ -383,6 +508,7 @@ async function purgeHardDeleteRowsWithFiles(args: {
|
|
|
383
508
|
tableHasTenantId: args.tableHasTenantId,
|
|
384
509
|
tenantId: args.tenantId,
|
|
385
510
|
entityName: args.entityName,
|
|
511
|
+
entity: args.entity,
|
|
386
512
|
row,
|
|
387
513
|
plan: args.plan,
|
|
388
514
|
files: args.files,
|
|
@@ -390,6 +516,8 @@ async function purgeHardDeleteRowsWithFiles(args: {
|
|
|
390
516
|
fileRefExecutor: args.fileRefExecutor,
|
|
391
517
|
systemUser: args.systemUser,
|
|
392
518
|
tdb: args.tdb,
|
|
519
|
+
kms: args.kms,
|
|
520
|
+
registry: args.registry,
|
|
393
521
|
onSkip,
|
|
394
522
|
});
|
|
395
523
|
if (ok) count++;
|
|
@@ -461,6 +589,7 @@ export async function runRetentionCleanup(
|
|
|
461
589
|
): Promise<RunRetentionCleanupResult> {
|
|
462
590
|
const { db, registry, tenantId, tenantPreset, now } = args;
|
|
463
591
|
const batchLimit = args.batchLimit ?? DEFAULT_BATCH_LIMIT;
|
|
592
|
+
const kms = args.kms ?? configuredPiiSubjectKms();
|
|
464
593
|
|
|
465
594
|
let hardDeleted = 0;
|
|
466
595
|
let softDeleted = 0;
|
|
@@ -531,7 +660,17 @@ export async function runRetentionCleanup(
|
|
|
531
660
|
const filePlan = planFileFields(entity.fields);
|
|
532
661
|
if (filePlan.singleFields.length === 0 && filePlan.multiFields.length === 0) {
|
|
533
662
|
hardDeleted += await purgeMatchingRows(db, proj.table, where, batchLimit, (id) =>
|
|
534
|
-
|
|
663
|
+
forgetRowAndEraseKeys({
|
|
664
|
+
db,
|
|
665
|
+
executor,
|
|
666
|
+
entity,
|
|
667
|
+
entityName,
|
|
668
|
+
tenantId,
|
|
669
|
+
systemUser,
|
|
670
|
+
row: { id },
|
|
671
|
+
kms,
|
|
672
|
+
registry,
|
|
673
|
+
}),
|
|
535
674
|
);
|
|
536
675
|
break;
|
|
537
676
|
}
|
|
@@ -546,12 +685,15 @@ export async function runRetentionCleanup(
|
|
|
546
685
|
batchLimit,
|
|
547
686
|
tenantId,
|
|
548
687
|
entityName,
|
|
688
|
+
entity,
|
|
549
689
|
plan: filePlan,
|
|
550
690
|
files: args.files,
|
|
551
691
|
entityExecutor: executor,
|
|
552
692
|
fileRefExecutor,
|
|
553
693
|
systemUser,
|
|
554
694
|
tdb,
|
|
695
|
+
kms,
|
|
696
|
+
registry,
|
|
555
697
|
skipped,
|
|
556
698
|
});
|
|
557
699
|
break;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import { configureBlindIndexKey } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
4
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
5
|
import {
|
|
5
6
|
buildEntityTable,
|
|
@@ -26,7 +27,8 @@ import {
|
|
|
26
27
|
testTenantId,
|
|
27
28
|
unsafePushTables,
|
|
28
29
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
|
-
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
30
|
+
import { resetBlindIndexKeyForTests, waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
31
|
+
import * as jose from "jose";
|
|
30
32
|
import * as z from "zod";
|
|
31
33
|
import { createChannelEmailFeature } from "../../channel-email/feature";
|
|
32
34
|
import { createInMemoryTransport, type EmailMessage } from "../../channel-email/types";
|
|
@@ -49,10 +51,18 @@ import { DeliveryHandlers, DeliveryJobs, DeliveryQueries } from "../constants";
|
|
|
49
51
|
import { collectChannels, createDeliveryService } from "../delivery-service";
|
|
50
52
|
import { createDeliveryFeature } from "../feature";
|
|
51
53
|
import { deliveryRenderJob, deliverySendJob } from "../jobs";
|
|
52
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
deliveryAttemptsTable,
|
|
56
|
+
notificationAddressOptOutsTable,
|
|
57
|
+
notificationPreferencesTable,
|
|
58
|
+
} from "../tables";
|
|
53
59
|
import { createDeliveryTestContext } from "../testing";
|
|
54
60
|
import type { DeliveryService } from "../types";
|
|
55
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
createUnsubscribeRoute,
|
|
63
|
+
signAddressUnsubscribeToken,
|
|
64
|
+
signUnsubscribeToken,
|
|
65
|
+
} from "../unsubscribe";
|
|
56
66
|
|
|
57
67
|
// --- Setup ---
|
|
58
68
|
|
|
@@ -371,6 +381,7 @@ beforeAll(async () => {
|
|
|
371
381
|
configValuesTable,
|
|
372
382
|
tenantMembershipsTable,
|
|
373
383
|
notificationPreferencesTable,
|
|
384
|
+
notificationAddressOptOutsTable,
|
|
374
385
|
inAppMessagesTable,
|
|
375
386
|
ticketTable,
|
|
376
387
|
});
|
|
@@ -1821,3 +1832,195 @@ describe("flow 18: priority → job priority mapping", () => {
|
|
|
1821
1832
|
expect(normal as number).toBeLessThan(low as number);
|
|
1822
1833
|
});
|
|
1823
1834
|
});
|
|
1835
|
+
|
|
1836
|
+
// --- Flow 19: address unsubscribe — recipient with no user account ---
|
|
1837
|
+
|
|
1838
|
+
describe("flow 19: address unsubscribe (route-based sends, no user account)", () => {
|
|
1839
|
+
const ADDRESS_BIDX_KEY = Buffer.alloc(32, 3).toString("base64");
|
|
1840
|
+
const FOREIGN_JWT_SECRET = "not-the-real-stack-secret-32-characters!";
|
|
1841
|
+
|
|
1842
|
+
beforeAll(() => {
|
|
1843
|
+
configureBlindIndexKey(ADDRESS_BIDX_KEY);
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
afterAll(() => {
|
|
1847
|
+
resetBlindIndexKeyForTests();
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
test("unsubscribe link opts the address out; same type/channel is skipped afterwards", async () => {
|
|
1851
|
+
const address = "flow19-recipient@test.com";
|
|
1852
|
+
|
|
1853
|
+
await deliveryService.notify(
|
|
1854
|
+
"app:notify:address-unsub-19a",
|
|
1855
|
+
{ route: { email: address }, data: { title: "X", body: "X" } },
|
|
1856
|
+
admin,
|
|
1857
|
+
admin.tenantId,
|
|
1858
|
+
);
|
|
1859
|
+
const sentBefore = await selectMany(db, deliveryAttemptsTable, {
|
|
1860
|
+
notificationType: "app:notify:address-unsub-19a",
|
|
1861
|
+
recipientAddress: address,
|
|
1862
|
+
});
|
|
1863
|
+
expect(sentBefore.every((l) => l["status"] === "sent")).toBe(true);
|
|
1864
|
+
expect(sentBefore.length).toBeGreaterThan(0);
|
|
1865
|
+
|
|
1866
|
+
// Sign with a mixed-case, padded variant of the same address — proves
|
|
1867
|
+
// normalization (trim + lowercase) happens before hashing, not just
|
|
1868
|
+
// exact string matches.
|
|
1869
|
+
const token = await signAddressUnsubscribeToken(
|
|
1870
|
+
{
|
|
1871
|
+
tenantId: admin.tenantId,
|
|
1872
|
+
address: ` ${address.toUpperCase()} `,
|
|
1873
|
+
notificationType: "app:notify:address-unsub-19a",
|
|
1874
|
+
channel: "email",
|
|
1875
|
+
},
|
|
1876
|
+
JWT_SECRET,
|
|
1877
|
+
);
|
|
1878
|
+
const res = await stack.app.request(`/delivery/unsubscribe?token=${token}`);
|
|
1879
|
+
expect(res.status).toBe(200);
|
|
1880
|
+
expect(await res.text()).toContain("unsubscribed");
|
|
1881
|
+
|
|
1882
|
+
// Clicking twice must stay a no-op (one row, no crash).
|
|
1883
|
+
const res2 = await stack.app.request(`/delivery/unsubscribe?token=${token}`);
|
|
1884
|
+
expect(res2.status).toBe(200);
|
|
1885
|
+
const optOutRows = await selectMany(db, notificationAddressOptOutsTable, {
|
|
1886
|
+
notificationType: "app:notify:address-unsub-19a",
|
|
1887
|
+
channel: "email",
|
|
1888
|
+
});
|
|
1889
|
+
expect(optOutRows).toHaveLength(1);
|
|
1890
|
+
|
|
1891
|
+
await deliveryService.notify(
|
|
1892
|
+
"app:notify:address-unsub-19a",
|
|
1893
|
+
{ route: { email: address }, data: { title: "Second", body: "Second" } },
|
|
1894
|
+
admin,
|
|
1895
|
+
admin.tenantId,
|
|
1896
|
+
);
|
|
1897
|
+
const skipped = await selectMany(db, deliveryAttemptsTable, {
|
|
1898
|
+
notificationType: "app:notify:address-unsub-19a",
|
|
1899
|
+
status: "skipped",
|
|
1900
|
+
error: "unsubscribed",
|
|
1901
|
+
});
|
|
1902
|
+
expect(skipped.length).toBeGreaterThanOrEqual(1);
|
|
1903
|
+
expect(skipped.every((l) => l["recipientAddress"] === null)).toBe(true);
|
|
1904
|
+
|
|
1905
|
+
// A different notificationType for the same address is unaffected.
|
|
1906
|
+
await deliveryService.notify(
|
|
1907
|
+
"app:notify:address-unsub-19b",
|
|
1908
|
+
{ route: { email: address }, data: { title: "Other type", body: "X" } },
|
|
1909
|
+
admin,
|
|
1910
|
+
admin.tenantId,
|
|
1911
|
+
);
|
|
1912
|
+
const otherTypeLogs = await selectMany(db, deliveryAttemptsTable, {
|
|
1913
|
+
notificationType: "app:notify:address-unsub-19b",
|
|
1914
|
+
recipientAddress: address,
|
|
1915
|
+
});
|
|
1916
|
+
expect(otherTypeLogs.some((l) => l["status"] === "sent")).toBe(true);
|
|
1917
|
+
|
|
1918
|
+
// Critical priority is never suppressed, same rule as user preferences.
|
|
1919
|
+
await deliveryService.notify(
|
|
1920
|
+
"app:notify:address-unsub-19a",
|
|
1921
|
+
{
|
|
1922
|
+
route: { email: address },
|
|
1923
|
+
data: { title: "Critical", body: "X" },
|
|
1924
|
+
priority: "critical",
|
|
1925
|
+
},
|
|
1926
|
+
admin,
|
|
1927
|
+
admin.tenantId,
|
|
1928
|
+
);
|
|
1929
|
+
const criticalLogs = await selectMany(db, deliveryAttemptsTable, {
|
|
1930
|
+
notificationType: "app:notify:address-unsub-19a",
|
|
1931
|
+
recipientAddress: address,
|
|
1932
|
+
priority: "critical",
|
|
1933
|
+
});
|
|
1934
|
+
expect(criticalLogs.some((l) => l["status"] === "sent")).toBe(true);
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
test("tampered token (wrong secret) is rejected and creates no opt-out row", async () => {
|
|
1938
|
+
const address = "flow19-tampered@test.com";
|
|
1939
|
+
const forgedToken = await signAddressUnsubscribeToken(
|
|
1940
|
+
{
|
|
1941
|
+
tenantId: admin.tenantId,
|
|
1942
|
+
address,
|
|
1943
|
+
notificationType: "app:notify:address-unsub-19c",
|
|
1944
|
+
channel: "email",
|
|
1945
|
+
},
|
|
1946
|
+
FOREIGN_JWT_SECRET,
|
|
1947
|
+
);
|
|
1948
|
+
|
|
1949
|
+
const res = await stack.app.request(`/delivery/unsubscribe?token=${forgedToken}`);
|
|
1950
|
+
expect(res.status).toBe(400);
|
|
1951
|
+
|
|
1952
|
+
const rows = await selectMany(db, notificationAddressOptOutsTable, {
|
|
1953
|
+
notificationType: "app:notify:address-unsub-19c",
|
|
1954
|
+
channel: "email",
|
|
1955
|
+
});
|
|
1956
|
+
expect(rows).toHaveLength(0);
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
test("signed token payload carries the address hash, never the plaintext address", async () => {
|
|
1960
|
+
const address = "flow19-no-plaintext@test.com";
|
|
1961
|
+
const token = await signAddressUnsubscribeToken(
|
|
1962
|
+
{
|
|
1963
|
+
tenantId: admin.tenantId,
|
|
1964
|
+
address,
|
|
1965
|
+
notificationType: "app:notify:address-unsub-19d",
|
|
1966
|
+
channel: "email",
|
|
1967
|
+
},
|
|
1968
|
+
JWT_SECRET,
|
|
1969
|
+
);
|
|
1970
|
+
|
|
1971
|
+
const payload = jose.decodeJwt(token);
|
|
1972
|
+
expect(payload["kind"]).toBe("address");
|
|
1973
|
+
expect(typeof payload["addressHash"]).toBe("string");
|
|
1974
|
+
expect(payload["addressHash"]).not.toBe(address);
|
|
1975
|
+
expect(JSON.stringify(payload)).not.toContain(address);
|
|
1976
|
+
});
|
|
1977
|
+
|
|
1978
|
+
test("opt-out is tenant-scoped: the same address in another tenant is unaffected", async () => {
|
|
1979
|
+
const address = "flow19-cross-tenant@test.com";
|
|
1980
|
+
const otherTenantId = testTenantId(915777);
|
|
1981
|
+
|
|
1982
|
+
const token = await signAddressUnsubscribeToken(
|
|
1983
|
+
{
|
|
1984
|
+
tenantId: admin.tenantId,
|
|
1985
|
+
address,
|
|
1986
|
+
notificationType: "app:notify:address-unsub-19e",
|
|
1987
|
+
channel: "email",
|
|
1988
|
+
},
|
|
1989
|
+
JWT_SECRET,
|
|
1990
|
+
);
|
|
1991
|
+
const res = await stack.app.request(`/delivery/unsubscribe?token=${token}`);
|
|
1992
|
+
expect(res.status).toBe(200);
|
|
1993
|
+
|
|
1994
|
+
await deliveryService.notify(
|
|
1995
|
+
"app:notify:address-unsub-19e",
|
|
1996
|
+
{ route: { email: address }, data: { title: "X", body: "X" } },
|
|
1997
|
+
admin,
|
|
1998
|
+
otherTenantId,
|
|
1999
|
+
);
|
|
2000
|
+
const otherTenantLogs = await selectMany(db, deliveryAttemptsTable, {
|
|
2001
|
+
notificationType: "app:notify:address-unsub-19e",
|
|
2002
|
+
recipientAddress: address,
|
|
2003
|
+
tenantId: otherTenantId,
|
|
2004
|
+
});
|
|
2005
|
+
expect(otherTenantLogs.some((l) => l["status"] === "sent")).toBe(true);
|
|
2006
|
+
});
|
|
2007
|
+
|
|
2008
|
+
test("signAddressUnsubscribeToken throws when no blind-index key is configured", async () => {
|
|
2009
|
+
resetBlindIndexKeyForTests();
|
|
2010
|
+
try {
|
|
2011
|
+
await expect(
|
|
2012
|
+
signAddressUnsubscribeToken(
|
|
2013
|
+
{
|
|
2014
|
+
tenantId: admin.tenantId,
|
|
2015
|
+
address: "no-key-configured@test.com",
|
|
2016
|
+
notificationType: "app:notify:address-unsub-19f",
|
|
2017
|
+
channel: "email",
|
|
2018
|
+
},
|
|
2019
|
+
JWT_SECRET,
|
|
2020
|
+
),
|
|
2021
|
+
).rejects.toThrow(/blind-index key/);
|
|
2022
|
+
} finally {
|
|
2023
|
+
configureBlindIndexKey(ADDRESS_BIDX_KEY);
|
|
2024
|
+
}
|
|
2025
|
+
});
|
|
2026
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { computeBlindIndex, configuredBlindIndexKey } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
|
+
import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import type { SessionUser, TenantId, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import { notificationAddressOptOutEntity, notificationAddressOptOutsTable } from "./tables";
|
|
6
|
+
import { isUniqueViolation } from "./upsert-preference";
|
|
7
|
+
|
|
8
|
+
const executor = createEventStoreExecutor(
|
|
9
|
+
notificationAddressOptOutsTable,
|
|
10
|
+
notificationAddressOptOutEntity,
|
|
11
|
+
{ entityName: "notification-address-opt-out" },
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
// Keyed hash of a recipient address that never had a user account — the
|
|
15
|
+
// plaintext address must never reach the opt-out row or the unsubscribe token.
|
|
16
|
+
// Undefined when no blind-index key is configured: there is nothing to
|
|
17
|
+
// compare against, so callers must treat that as "can't tell".
|
|
18
|
+
export function hashUnsubscribeAddress(address: string): string | undefined {
|
|
19
|
+
const key = configuredBlindIndexKey();
|
|
20
|
+
if (key === undefined) return undefined;
|
|
21
|
+
return computeBlindIndex(key, address.trim().toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type UpsertAddressOptOutInput = {
|
|
25
|
+
readonly tenantId: TenantId;
|
|
26
|
+
readonly addressHash: string;
|
|
27
|
+
readonly notificationType: string;
|
|
28
|
+
readonly channel: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type OptOutLookupRow = { readonly id: string };
|
|
32
|
+
|
|
33
|
+
async function lookup(
|
|
34
|
+
db: TenantDb,
|
|
35
|
+
tenantId: TenantId,
|
|
36
|
+
addressHash: string,
|
|
37
|
+
notificationType: string,
|
|
38
|
+
channel: string,
|
|
39
|
+
): Promise<OptOutLookupRow | undefined> {
|
|
40
|
+
return fetchOne<OptOutLookupRow>(db, notificationAddressOptOutsTable, {
|
|
41
|
+
tenantId,
|
|
42
|
+
addressHash,
|
|
43
|
+
notificationType,
|
|
44
|
+
channel,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create-or-noop: opting the same address out twice must not produce a
|
|
50
|
+
* second row or fail the second click. There is no update path — unlike
|
|
51
|
+
* user preferences an address opt-out has no `enabled` flag to flip back.
|
|
52
|
+
*/
|
|
53
|
+
export async function upsertAddressOptOut(
|
|
54
|
+
db: TenantDb,
|
|
55
|
+
actor: SessionUser,
|
|
56
|
+
input: UpsertAddressOptOutInput,
|
|
57
|
+
): Promise<WriteResult<UpsertAddressOptOutInput>> {
|
|
58
|
+
const existing = await lookup(
|
|
59
|
+
db,
|
|
60
|
+
input.tenantId,
|
|
61
|
+
input.addressHash,
|
|
62
|
+
input.notificationType,
|
|
63
|
+
input.channel,
|
|
64
|
+
);
|
|
65
|
+
if (existing) return { isSuccess: true, data: input };
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const result = await executor.create(
|
|
69
|
+
{
|
|
70
|
+
addressHash: input.addressHash,
|
|
71
|
+
notificationType: input.notificationType,
|
|
72
|
+
channel: input.channel,
|
|
73
|
+
},
|
|
74
|
+
actor,
|
|
75
|
+
db,
|
|
76
|
+
);
|
|
77
|
+
if (!result.isSuccess) return result;
|
|
78
|
+
return { isSuccess: true, data: input };
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Race-fallback mirrors upsertPreference: another request created the
|
|
81
|
+
// row between our lookup and executor.create. Nothing to update to —
|
|
82
|
+
// the existing row already IS the opt-out, so the race loser just
|
|
83
|
+
// reports success too.
|
|
84
|
+
if (!isUniqueViolation(err)) throw err;
|
|
85
|
+
return { isSuccess: true, data: input };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.313.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "delivery direct sends to a route address can now be unsubscribed via a signed link",
|
|
6
|
+
"detail": "ctx.notify(type, { route: { email } }) direct sends had no unsubscribe\npath: only sends to a user account with a notification-preference row\ncould opt out, so a recipient with no account could never stop the mail.\nAdded hashUnsubscribeAddress (keyed blind-index hash of the trimmed,\nlowercased address — the plaintext address never reaches the token or\nthe opt-out table), signAddressUnsubscribeToken (HS256 JWT, issuer\n\"kumiko:unsubscribe\", no expiry — a leaked link can only opt one\naddress out of one notificationType/channel, and there is no signed-in\nflow to request a fresh one), and a new notification-address-opt-out\nevent-sourced entity/table. createUnsubscribeRoute now accepts both the\nexisting user token and the new address token on the same route; the\nexisting signUnsubscribeToken and its JWT shape are unchanged. deliverDirect\nnow skips a channel (logDelivery status \"skipped\", error \"unsubscribed\", recipientAddress null)\nwhen the destination address has an opt-out row for that tenant/\nnotificationType/channel, unless priority is \"critical\" — the same rule\nuser-preference suppression already follows. The lookup is skipped\nentirely when no blind-index key is configured.",
|
|
7
|
+
"migration": "Run `kumiko migrate generate` to add the notification address opt-out table."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.296.0",
|
|
4
11
|
"type": "breaking",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
|
|
5
|
+
// Exact match only — unlike notification-preferences there is no wildcard
|
|
6
|
+
// ("*") semantics for address opt-outs, the token that creates a row always
|
|
7
|
+
// carries one concrete notificationType/channel pair.
|
|
8
|
+
export async function isAddressOptedOut(
|
|
9
|
+
db: DbConnection,
|
|
10
|
+
tenantId: TenantId,
|
|
11
|
+
addressHash: string,
|
|
12
|
+
notificationType: string,
|
|
13
|
+
channel: string,
|
|
14
|
+
): Promise<boolean> {
|
|
15
|
+
const rows = await unsafeReadRetrying<{ readonly id: string }>(
|
|
16
|
+
db,
|
|
17
|
+
`SELECT id FROM read_notification_address_opt_outs
|
|
18
|
+
WHERE tenant_id = $1 AND address_hash = $2 AND notification_type = $3 AND channel = $4
|
|
19
|
+
LIMIT 1`,
|
|
20
|
+
[tenantId, addressHash, notificationType, channel],
|
|
21
|
+
);
|
|
22
|
+
return rows.length > 0;
|
|
23
|
+
}
|