@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.
Files changed (63) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/extension-points-typed.test.ts +4 -0
  3. package/src/agent-tools/__tests__/agent-manifest.test.ts +30 -0
  4. package/src/agent-tools/agent-manifest.ts +7 -1
  5. package/src/agent-tools/changes.json +8 -1
  6. package/src/audit/changes.json +6 -0
  7. package/src/audit/feature.ts +1 -2
  8. package/src/audit/i18n.ts +3 -0
  9. package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +424 -0
  10. package/src/auth-email-password/changes.json +6 -0
  11. package/src/auth-email-password/handlers/signup-confirm.write.ts +75 -5
  12. package/src/auth-email-password/handlers/signup-request.write.ts +60 -0
  13. package/src/auth-email-password/signup-token-store.ts +87 -1
  14. package/src/auth-email-password/web/auth-client.ts +10 -1
  15. package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +160 -0
  16. package/src/data-retention/changes.json +6 -0
  17. package/src/data-retention/run-retention-cleanup.ts +160 -18
  18. package/src/delivery/__tests__/delivery.integration.test.ts +206 -3
  19. package/src/delivery/address-opt-out.ts +87 -0
  20. package/src/delivery/changes.json +7 -0
  21. package/src/delivery/db/queries/address-opt-outs.ts +23 -0
  22. package/src/delivery/delivery-service.ts +32 -0
  23. package/src/delivery/feature.ts +9 -1
  24. package/src/delivery/index.ts +5 -0
  25. package/src/delivery/tables.ts +37 -0
  26. package/src/delivery/unsubscribe.ts +101 -16
  27. package/src/delivery/upsert-preference.ts +1 -1
  28. package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +40 -0
  29. package/src/file-derivatives/changes.json +6 -0
  30. package/src/file-derivatives/feature.ts +5 -0
  31. package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +23 -6
  32. package/src/shared/index.ts +10 -1
  33. package/src/shared/run-in-sub-transaction.ts +35 -0
  34. package/src/shared/signup-handover.ts +67 -0
  35. package/src/shared/single-use-token-store.ts +25 -5
  36. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +44 -0
  37. package/src/subscription-stripe/changes.json +9 -1
  38. package/src/subscription-stripe/feature.ts +13 -1
  39. package/src/subscription-stripe/plugin-methods.ts +17 -2
  40. package/src/tenant/__tests__/is-tenant-serving-public-content.test.ts +21 -0
  41. package/src/tenant/changes.json +6 -0
  42. package/src/tenant/index.ts +1 -0
  43. package/src/tenant/is-tenant-serving-public-content.ts +13 -0
  44. package/src/tenant/web/__tests__/member-roles-cell.test.tsx +59 -0
  45. package/src/tenant/web/__tests__/member-status-cell.test.tsx +46 -0
  46. package/src/tenant/web/member-roles-cell.tsx +11 -2
  47. package/src/tenant/web/member-status-cell.tsx +6 -2
  48. package/src/tenant/web/translate-or-raw.ts +13 -0
  49. package/src/tenant-handover/__tests__/claim-storage-usage.integration.test.ts +201 -0
  50. package/src/tenant-handover/__tests__/claim.integration.test.ts +115 -2
  51. package/src/tenant-handover/__tests__/transfer-graph.test.ts +15 -0
  52. package/src/tenant-handover/changes.json +12 -0
  53. package/src/tenant-handover/events.ts +6 -0
  54. package/src/tenant-handover/feature.ts +11 -0
  55. package/src/tenant-handover/handlers/claim.write.ts +8 -25
  56. package/src/tenant-handover/move-entity-graph.ts +30 -1
  57. package/src/tenant-handover/root-anchor.ts +50 -0
  58. package/src/tenant-handover/signup-handover-provider.ts +62 -0
  59. package/src/tenant-handover/transfer-graph.ts +6 -2
  60. package/src/user-data-rights/changes.json +6 -0
  61. package/src/user-data-rights/feature.ts +1 -2
  62. package/src/user-data-rights/i18n.ts +3 -0
  63. 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
- executor.forget({ id }, systemUser, tdb),
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 { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
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 { createUnsubscribeRoute, signUnsubscribeToken } from "../unsubscribe";
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
+ }