@cosmicdrift/kumiko-bundled-features 0.313.0 → 0.315.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 (38) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/changes.json +6 -0
  3. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +66 -0
  4. package/src/auth-email-password/web/signup-complete-screen.tsx +10 -4
  5. package/src/compliance-profiles/resolve-for-tenant.ts +15 -5
  6. package/src/data-retention/__tests__/retention-cleanup-files.integration.test.ts +6 -6
  7. package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +1 -1
  8. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +10 -10
  9. package/src/data-retention/changes.json +6 -0
  10. package/src/data-retention/feature.ts +1 -1
  11. package/src/data-retention/handlers/policy-for.query.ts +14 -31
  12. package/src/data-retention/index.ts +2 -0
  13. package/src/data-retention/resolve-for-tenant.ts +25 -17
  14. package/src/data-retention/resolve-tenant-preset.ts +5 -2
  15. package/src/data-retention/run-retention-cleanup.ts +5 -4
  16. package/src/delivery/__tests__/delivery.integration.test.ts +189 -19
  17. package/src/delivery/address-opt-out.ts +49 -21
  18. package/src/delivery/changes.json +12 -0
  19. package/src/delivery/constants.ts +1 -0
  20. package/src/delivery/delivery-service.ts +48 -27
  21. package/src/delivery/feature.ts +5 -1
  22. package/src/delivery/handlers/unsubscribe-address.write.ts +40 -0
  23. package/src/delivery/handlers/unsubscribe-user.write.ts +43 -0
  24. package/src/delivery/index.ts +1 -0
  25. package/src/delivery/public-names.ts +6 -0
  26. package/src/delivery/unsubscribe.ts +144 -67
  27. package/src/delivery/upsert-preference.ts +74 -60
  28. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +1 -0
  29. package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +2 -1
  30. package/src/file-derivatives/__tests__/public-variant-predicate-args.integration.test.ts +2 -1
  31. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +4 -1
  32. package/src/shared/index.ts +1 -0
  33. package/src/shared/is-tenant-db.ts +9 -0
  34. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +5 -1
  35. package/src/user-data-rights/__tests__/retention-preset-forget.integration.test.ts +272 -0
  36. package/src/user-data-rights/run-forget-cleanup.ts +6 -1
  37. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +9 -7
  38. package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +7 -2
@@ -47,7 +47,12 @@ import { TenantQueries } from "../../tenant/constants";
47
47
  import { createTenantFeature } from "../../tenant/feature";
48
48
  import { tenantMembershipsTable } from "../../tenant/membership-table";
49
49
  import { tenantEntity } from "../../tenant/schema/tenant";
50
- import { DeliveryHandlers, DeliveryJobs, DeliveryQueries } from "../constants";
50
+ import {
51
+ DELIVERY_UNSUBSCRIBE_PATH,
52
+ DeliveryHandlers,
53
+ DeliveryJobs,
54
+ DeliveryQueries,
55
+ } from "../constants";
51
56
  import { collectChannels, createDeliveryService } from "../delivery-service";
52
57
  import { createDeliveryFeature } from "../feature";
53
58
  import { deliveryRenderJob, deliverySendJob } from "../jobs";
@@ -69,7 +74,7 @@ import {
69
74
  let stack: TestStack;
70
75
  let db: DbConnection;
71
76
  let deliveryService: DeliveryService;
72
- const JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
77
+ const UNSUBSCRIBE_SECRET = "test-stack-unsubscribe-secret-32-chars-min!!";
73
78
 
74
79
  // Email test infrastructure
75
80
  const emailTransport = createInMemoryTransport();
@@ -368,12 +373,10 @@ beforeAll(async () => {
368
373
  deliveryService = ctx.deliveryService;
369
374
  return ctx;
370
375
  },
376
+ extraRoutes: [createUnsubscribeRoute({ secret: UNSUBSCRIBE_SECRET })],
371
377
  });
372
378
  db = stack.db;
373
379
 
374
- // Mount unsubscribe route BEFORE any requests (Hono router locks after first match)
375
- stack.app.route("/delivery", createUnsubscribeRoute({ db, jwtSecret: JWT_SECRET }));
376
-
377
380
  // deliveryAttemptsTable is auto-pushed by setupTestStack as MSP-projection-table;
378
381
  // notificationPreferencesTable is an ES-entity, so it still needs explicit
379
382
  // push here (entity-tables are not auto-provisioned — only projection ones).
@@ -798,10 +801,10 @@ describe("flow 7: unsubscribe endpoint", () => {
798
801
  notificationType: "app:notify:announcement",
799
802
  channel: "inApp",
800
803
  },
801
- JWT_SECRET,
804
+ UNSUBSCRIBE_SECRET,
802
805
  );
803
806
 
804
- const res = await stack.app.request(`/delivery/unsubscribe?token=${token}`);
807
+ const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
805
808
  expect(res.status).toBe(200);
806
809
  const text = await res.text();
807
810
  expect(text).toContain("unsubscribed");
@@ -819,14 +822,100 @@ describe("flow 7: unsubscribe endpoint", () => {
819
822
  expect(pref?.["enabled"]).toBe(false);
820
823
  });
821
824
 
825
+ test("preference row belongs to the token's userId, not the dispatching system user", async () => {
826
+ const token = await signUnsubscribeToken(
827
+ {
828
+ userId: user1.id,
829
+ tenantId: user1.tenantId,
830
+ notificationType: "app:notify:flow7-actor-check",
831
+ channel: "inApp",
832
+ },
833
+ UNSUBSCRIBE_SECRET,
834
+ );
835
+
836
+ const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
837
+ expect(res.status).toBe(200);
838
+
839
+ const rows = await selectMany(db, notificationPreferencesTable, {
840
+ userId: user1.id,
841
+ notificationType: "app:notify:flow7-actor-check",
842
+ channel: "inApp",
843
+ });
844
+ expect(rows).toHaveLength(1);
845
+ expect(rows[0]?.["enabled"]).toBe(false);
846
+ });
847
+
822
848
  test("invalid token returns 400", async () => {
823
- const res = await stack.app.request("/delivery/unsubscribe?token=invalid-jwt-token");
849
+ const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=invalid-jwt-token`);
824
850
  expect(res.status).toBe(400);
825
851
  });
826
852
 
827
853
  test("missing token returns 400", async () => {
828
- const res = await stack.app.request("/delivery/unsubscribe");
854
+ const res = await stack.app.request(DELIVERY_UNSUBSCRIBE_PATH);
855
+ expect(res.status).toBe(400);
856
+ });
857
+
858
+ test("normal user cannot dispatch the unsubscribe write handlers directly", async () => {
859
+ const error = await stack.http.writeErr(
860
+ DeliveryHandlers.unsubscribeAddress,
861
+ { addressHash: "x".repeat(32), notificationType: "app:notify:direct-call", channel: "email" },
862
+ user1,
863
+ );
864
+ expect(error.code).toBe("access_denied");
865
+
866
+ const rows = await selectMany(db, notificationAddressOptOutsTable, {
867
+ notificationType: "app:notify:direct-call",
868
+ channel: "email",
869
+ });
870
+ expect(rows).toHaveLength(0);
871
+
872
+ const userError = await stack.http.writeErr(
873
+ DeliveryHandlers.unsubscribeUser,
874
+ { userId: user2.id, notificationType: "app:notify:direct-call", channel: "email" },
875
+ user1,
876
+ );
877
+ expect(userError.code).toBe("access_denied");
878
+
879
+ const preferenceRows = await selectMany(db, notificationPreferencesTable, {
880
+ userId: user2.id,
881
+ notificationType: "app:notify:direct-call",
882
+ });
883
+ expect(preferenceRows).toHaveLength(0);
884
+ });
885
+
886
+ test("wrong issuer is rejected with the generic invalid-token code, not a jose error text", async () => {
887
+ const tamperedIssuer = await new jose.SignJWT({
888
+ tenantId: user2.tenantId,
889
+ notificationType: "app:notify:wrong-issuer",
890
+ channel: "inApp",
891
+ })
892
+ .setProtectedHeader({ alg: "HS256" })
893
+ .setSubject(user2.id)
894
+ .setIssuer("not-kumiko:unsubscribe")
895
+ .setIssuedAt()
896
+ .sign(new TextEncoder().encode(UNSUBSCRIBE_SECRET));
897
+
898
+ const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${tamperedIssuer}`);
899
+ expect(res.status).toBe(400);
900
+ const body = (await res.json()) as { error?: { code?: string; message?: string } };
901
+ expect(body.error?.code).toBe("unsubscribe_token_invalid");
902
+ expect(JSON.stringify(body)).not.toContain("signature verification failed");
903
+ expect(JSON.stringify(body)).not.toContain("claim");
904
+
905
+ const rows = await selectMany(db, notificationPreferencesTable, {
906
+ userId: user2.id,
907
+ notificationType: "app:notify:wrong-issuer",
908
+ channel: "inApp",
909
+ });
910
+ expect(rows).toHaveLength(0);
911
+ });
912
+
913
+ test("a real stack session JWT is rejected at the unsubscribe route", async () => {
914
+ const sessionToken = await stack.jwt.sign(user1);
915
+ const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${sessionToken}`);
829
916
  expect(res.status).toBe(400);
917
+ const body = (await res.json()) as { error?: { code?: string } };
918
+ expect(body.error?.code).toBe("unsubscribe_token_invalid");
830
919
  });
831
920
  });
832
921
 
@@ -1481,10 +1570,10 @@ describe("flow 16: repeated unsubscribe clicks are idempotent", () => {
1481
1570
  notificationType: "app:notify:concurrent-unsub",
1482
1571
  channel: "email",
1483
1572
  },
1484
- JWT_SECRET,
1573
+ UNSUBSCRIBE_SECRET,
1485
1574
  );
1486
1575
 
1487
- const url = `/delivery/unsubscribe?token=${token}`;
1576
+ const url = `${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`;
1488
1577
  const results = await Promise.all([
1489
1578
  stack.app.request(url),
1490
1579
  stack.app.request(url),
@@ -1873,14 +1962,14 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
1873
1962
  notificationType: "app:notify:address-unsub-19a",
1874
1963
  channel: "email",
1875
1964
  },
1876
- JWT_SECRET,
1965
+ UNSUBSCRIBE_SECRET,
1877
1966
  );
1878
- const res = await stack.app.request(`/delivery/unsubscribe?token=${token}`);
1967
+ const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
1879
1968
  expect(res.status).toBe(200);
1880
1969
  expect(await res.text()).toContain("unsubscribed");
1881
1970
 
1882
1971
  // Clicking twice must stay a no-op (one row, no crash).
1883
- const res2 = await stack.app.request(`/delivery/unsubscribe?token=${token}`);
1972
+ const res2 = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
1884
1973
  expect(res2.status).toBe(200);
1885
1974
  const optOutRows = await selectMany(db, notificationAddressOptOutsTable, {
1886
1975
  notificationType: "app:notify:address-unsub-19a",
@@ -1946,7 +2035,7 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
1946
2035
  FOREIGN_JWT_SECRET,
1947
2036
  );
1948
2037
 
1949
- const res = await stack.app.request(`/delivery/unsubscribe?token=${forgedToken}`);
2038
+ const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${forgedToken}`);
1950
2039
  expect(res.status).toBe(400);
1951
2040
 
1952
2041
  const rows = await selectMany(db, notificationAddressOptOutsTable, {
@@ -1965,7 +2054,7 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
1965
2054
  notificationType: "app:notify:address-unsub-19d",
1966
2055
  channel: "email",
1967
2056
  },
1968
- JWT_SECRET,
2057
+ UNSUBSCRIBE_SECRET,
1969
2058
  );
1970
2059
 
1971
2060
  const payload = jose.decodeJwt(token);
@@ -1986,9 +2075,9 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
1986
2075
  notificationType: "app:notify:address-unsub-19e",
1987
2076
  channel: "email",
1988
2077
  },
1989
- JWT_SECRET,
2078
+ UNSUBSCRIBE_SECRET,
1990
2079
  );
1991
- const res = await stack.app.request(`/delivery/unsubscribe?token=${token}`);
2080
+ const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
1992
2081
  expect(res.status).toBe(200);
1993
2082
 
1994
2083
  await deliveryService.notify(
@@ -2016,11 +2105,92 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
2016
2105
  notificationType: "app:notify:address-unsub-19f",
2017
2106
  channel: "email",
2018
2107
  },
2019
- JWT_SECRET,
2108
+ UNSUBSCRIBE_SECRET,
2020
2109
  ),
2021
2110
  ).rejects.toThrow(/blind-index key/);
2022
2111
  } finally {
2023
2112
  configureBlindIndexKey(ADDRESS_BIDX_KEY);
2024
2113
  }
2025
2114
  });
2115
+
2116
+ // #3275: deliverToUser must also honor an address opt-out, not just
2117
+ // deliverDirect's route-based sends — the two paths shared the same
2118
+ // isAddressSuppressed check as of this change.
2119
+ test("account send is suppressed when the resolved address has an opt-out row (#3275)", async () => {
2120
+ await stack.redis.redis.del(RATE_KEY_EMAIL);
2121
+ const notificationType = "app:notify:account-unsub-20";
2122
+ const address = testEmail(user1.id);
2123
+
2124
+ const token = await signAddressUnsubscribeToken(
2125
+ { tenantId: user1.tenantId, address, notificationType, channel: "email" },
2126
+ UNSUBSCRIBE_SECRET,
2127
+ );
2128
+ const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
2129
+ expect(res.status).toBe(200);
2130
+
2131
+ emailTransport.sent.length = 0;
2132
+ stack.events.reset();
2133
+
2134
+ await stack.http.writeOk(
2135
+ "app:write:send-notification",
2136
+ { notificationType, toUserId: user1.id, title: "Konto-Unsub", body: "X" },
2137
+ admin,
2138
+ );
2139
+
2140
+ expect(emailTransport.sent.some((e) => e.to === address)).toBe(false);
2141
+ const emailLogs = await selectMany(db, deliveryAttemptsTable, {
2142
+ notificationType,
2143
+ recipientId: user1.id,
2144
+ channel: "email",
2145
+ });
2146
+ expect(emailLogs.some((l) => l["status"] === "skipped" && l["error"] === "unsubscribed")).toBe(
2147
+ true,
2148
+ );
2149
+ expect(emailLogs.every((l) => l["recipientAddress"] !== address)).toBe(true);
2150
+
2151
+ // inApp is unaffected — the opt-out is per-channel.
2152
+ const inAppNotifs = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
2153
+ expect(inAppNotifs.some((e) => e.data["userId"] === user1.id)).toBe(true);
2154
+
2155
+ // Critical priority still gets delivered, same rule as the direct-route path.
2156
+ emailTransport.sent.length = 0;
2157
+ await deliveryService.notify(
2158
+ notificationType,
2159
+ { to: user1.id, data: { title: "Critical", body: "X" }, priority: "critical" },
2160
+ admin,
2161
+ admin.tenantId,
2162
+ );
2163
+ expect(emailTransport.sent.some((e) => e.to === address)).toBe(true);
2164
+ });
2165
+
2166
+ // Address-path counterpart to flow 16 — same deterministic-aggregate-id
2167
+ // race, but through upsertAddressOptOut's create-or-noop instead of
2168
+ // upsertPreference's create-or-update.
2169
+ test("clicking the same address unsubscribe link three times concurrently does not error", async () => {
2170
+ const address = "flow19-concurrent@test.com";
2171
+ const notificationType = "app:notify:address-unsub-concurrent";
2172
+
2173
+ const token = await signAddressUnsubscribeToken(
2174
+ { tenantId: admin.tenantId, address, notificationType, channel: "email" },
2175
+ UNSUBSCRIBE_SECRET,
2176
+ );
2177
+
2178
+ const url = `${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`;
2179
+ const results = await Promise.all([
2180
+ stack.app.request(url),
2181
+ stack.app.request(url),
2182
+ stack.app.request(url),
2183
+ ]);
2184
+
2185
+ for (const res of results) {
2186
+ expect(res.status).toBe(200);
2187
+ }
2188
+
2189
+ const rows = await selectMany(db, notificationAddressOptOutsTable, {
2190
+ tenantId: admin.tenantId,
2191
+ notificationType,
2192
+ channel: "email",
2193
+ });
2194
+ expect(rows).toHaveLength(1);
2195
+ });
2026
2196
  });
@@ -2,8 +2,8 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { computeBlindIndex, configuredBlindIndexKey } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
4
4
  import type { SessionUser, TenantId, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { generateDeterministicId } from "@cosmicdrift/kumiko-framework/utils";
5
6
  import { notificationAddressOptOutEntity, notificationAddressOptOutsTable } from "./tables";
6
- import { isUniqueViolation } from "./upsert-preference";
7
7
 
8
8
  const executor = createEventStoreExecutor(
9
9
  notificationAddressOptOutsTable,
@@ -45,6 +45,22 @@ async function lookup(
45
45
  });
46
46
  }
47
47
 
48
+ // One row per (tenant, addressHash, type, channel): mirrors
49
+ // preferenceAggregateId in upsert-preference.ts — concurrent first-time
50
+ // opt-outs collide on the same stream at append, not on the projection's
51
+ // unique index.
52
+ function addressOptOutAggregateId(
53
+ tenantId: TenantId,
54
+ addressHash: string,
55
+ notificationType: string,
56
+ channel: string,
57
+ ): string {
58
+ return generateDeterministicId(
59
+ "delivery:notification-address-opt-out",
60
+ `${tenantId}|${addressHash}|${notificationType}|${channel}`,
61
+ );
62
+ }
63
+
48
64
  /**
49
65
  * Create-or-noop: opting the same address out twice must not produce a
50
66
  * second row or fail the second click. There is no update path — unlike
@@ -64,24 +80,36 @@ export async function upsertAddressOptOut(
64
80
  );
65
81
  if (existing) return { isSuccess: true, data: input };
66
82
 
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
- }
83
+ const id = addressOptOutAggregateId(
84
+ input.tenantId,
85
+ input.addressHash,
86
+ input.notificationType,
87
+ input.channel,
88
+ );
89
+ const created = await executor.create(
90
+ {
91
+ id,
92
+ addressHash: input.addressHash,
93
+ notificationType: input.notificationType,
94
+ channel: input.channel,
95
+ },
96
+ actor,
97
+ db,
98
+ );
99
+ if (created.isSuccess) return { isSuccess: true, data: input };
100
+ // Race-fallback: another request's create already won this deterministic
101
+ // id between our lookup and this create — the existing row already IS the
102
+ // opt-out, so the race loser just reports success too.
103
+ if (created.error.code !== "version_conflict") return created;
104
+ // A conflict without a row means the stream exists but its row is gone —
105
+ // report that instead of a silent "unsubscribed" that never persisted.
106
+ const afterRace = await lookup(
107
+ db,
108
+ input.tenantId,
109
+ input.addressHash,
110
+ input.notificationType,
111
+ input.channel,
112
+ );
113
+ if (!afterRace) return created;
114
+ return { isSuccess: true, data: input };
87
115
  }
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.315.0",
4
+ "type": "fix",
5
+ "title": "delivery honors an address opt-out on sends to a user account",
6
+ "detail": "`deliverToUser` only checked the user's notification preferences, so an\naddress that had been opted out via an address unsubscribe token still got\nmail once it belonged to a user account. It now runs the same check\n`deliverDirect` already applied: when the resolved channel address has a\n`notification-address-opt-out` row for that tenant, notificationType and\nchannel, the attempt is logged as `skipped` / `unsubscribed` with\n`recipientAddress: null`. Critical priority still bypasses it\n(kumiko-framework#3275)."
7
+ },
8
+ {
9
+ "version": "0.315.0",
10
+ "type": "improvement",
11
+ "title": "Delivery unsubscribe route is now mountable via extraRoutes",
12
+ "detail": "`createUnsubscribeRoute` previously returned a raw Hono app that needed a\n`DbConnection` (`{ db, jwtSecret }`) and had to be mounted by hand with\n`stack.app.route(...)`, bypassing the framework's request pipeline. It now\nreturns an `ExtraRouteDefinition` built via `signatureRoute`, taking only\n`{ secret }`, and mounts at the fixed path `GET /api/delivery/unsubscribe?token=`\n(exported as `DELIVERY_UNSUBSCRIBE_PATH`). Mount it with\n`extraRoutes: [createUnsubscribeRoute({ secret: env.UNSUBSCRIBE_SECRET })]`.\n`signUnsubscribeToken` / `signAddressUnsubscribeToken` must sign with the\nsame secret. The secret must be at least 32 characters and must NOT reuse\nthe app's session `JWT_SECRET` — all three entry points fail fast on a\nshort secret.\nThe write itself now goes through two new SystemAdmin-only handlers,\n`delivery:write:unsubscribe-address` and `delivery:write:unsubscribe-user`,\ndispatched via `dispatchSystemWrite` once the route's `verify()` has proven\nthe token's authenticity. An invalid or expired token always responds 400\nwith `{ error: { code: \"unsubscribe_token_invalid\" } }`, never leaking the\nunderlying JWT-library error text.\nNew notification-preference and notification-address-opt-out rows get a\ndeterministic aggregate id derived from (tenant, user or address hash,\nnotificationType, channel), so concurrent clicks on the same link collide\nat the event-store append and converge on one row instead of failing with\na unique or version conflict. Existing rows keep their ids."
13
+ },
2
14
  {
3
15
  "version": "0.313.0",
4
16
  "type": "breaking",
@@ -11,6 +11,7 @@ export {
11
11
  DELIVERY_FEATURE,
12
12
  DELIVERY_LOG_SCREEN_ID,
13
13
  DELIVERY_STATUS_CELL_COMPONENT,
14
+ DELIVERY_UNSUBSCRIBE_PATH,
14
15
  DeliveryErrors,
15
16
  DeliveryHandlers,
16
17
  DeliveryJobNames,
@@ -321,6 +321,20 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
321
321
  };
322
322
  }
323
323
 
324
+ // Shared by deliverToUser (resolved account address) and deliverDirect (a
325
+ // route address with no user account) — same suppression rule either way:
326
+ // no blind-index key configured means no hash, so nothing to look up.
327
+ async function isAddressSuppressed(
328
+ address: string,
329
+ tenantId: TenantId,
330
+ notificationType: string,
331
+ channelName: string,
332
+ ): Promise<boolean> {
333
+ const addressHash = hashUnsubscribeAddress(address);
334
+ if (addressHash === undefined) return false;
335
+ return isAddressOptedOut(db, tenantId, addressHash, notificationType, channelName);
336
+ }
337
+
324
338
  // Check if user has disabled this notification+channel combo.
325
339
  // Specificity order: exact > any wildcard. When only wildcards match and they
326
340
  // disagree, "disabled wins" — the user has asked to be opted out somewhere,
@@ -439,6 +453,23 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
439
453
  continue;
440
454
  }
441
455
 
456
+ if (
457
+ priority !== "critical" &&
458
+ (await isAddressSuppressed(address, tenantId, notificationType, channel.name))
459
+ ) {
460
+ await logDelivery({
461
+ tenantId,
462
+ notificationType,
463
+ channel: channel.name,
464
+ recipientId: userId,
465
+ recipientAddress: null,
466
+ status: "skipped",
467
+ error: "unsubscribed",
468
+ priority,
469
+ });
470
+ continue;
471
+ }
472
+
442
473
  await deliverViaChannel({
443
474
  channel,
444
475
  address,
@@ -482,33 +513,23 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
482
513
  if (!address) continue;
483
514
 
484
515
  // Address opt-out (critical priority skips it, same rule as user
485
- // preferences). No blind-index key configured → no hash → nothing to
486
- // look up, skip the check instead of failing the send.
487
- if (priority !== "critical") {
488
- const addressHash = hashUnsubscribeAddress(address);
489
- if (addressHash !== undefined) {
490
- const optedOut = await isAddressOptedOut(
491
- db,
492
- tenantId,
493
- addressHash,
494
- notificationType,
495
- channel.name,
496
- );
497
- if (optedOut) {
498
- await logDelivery({
499
- tenantId,
500
- notificationType,
501
- channel: channel.name,
502
- recipientId,
503
- // The recipient withdrew — suppressed attempts must not keep recording the address.
504
- recipientAddress: null,
505
- status: "skipped",
506
- error: "unsubscribed",
507
- priority,
508
- });
509
- continue;
510
- }
511
- }
516
+ // preferences).
517
+ if (
518
+ priority !== "critical" &&
519
+ (await isAddressSuppressed(address, tenantId, notificationType, channel.name))
520
+ ) {
521
+ await logDelivery({
522
+ tenantId,
523
+ notificationType,
524
+ channel: channel.name,
525
+ recipientId,
526
+ // The recipient withdrew — suppressed attempts must not keep recording the address.
527
+ recipientAddress: null,
528
+ status: "skipped",
529
+ error: "unsubscribed",
530
+ priority,
531
+ });
532
+ continue;
512
533
  }
513
534
 
514
535
  if (rateLimit) {
@@ -18,6 +18,8 @@ import { deliveryAttemptSchema } from "./events";
18
18
  import { logQuery } from "./handlers/log.query";
19
19
  import { preferencesQuery } from "./handlers/preferences.query";
20
20
  import { setPreferenceWrite } from "./handlers/set-preference.write";
21
+ import { unsubscribeAddressWrite } from "./handlers/unsubscribe-address.write";
22
+ import { unsubscribeUserWrite } from "./handlers/unsubscribe-user.write";
21
23
  import { DELIVERY_I18N } from "./i18n";
22
24
  import { deliveryRenderJob, deliverySendJob } from "./jobs";
23
25
  import {
@@ -42,7 +44,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
42
44
  const resolvedAccess = options?.access ?? access.admin;
43
45
  return defineFeature("delivery", (r) => {
44
46
  r.describe(
45
- "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
47
+ "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe links are served by `createUnsubscribeRoute({ secret })` mounted via the app's `extraRoutes` at `GET /api/delivery/unsubscribe?token=`; sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret.",
46
48
  );
47
49
  r.uiHints({
48
50
  displayLabel: "Notifications \u00b7 Dispatch Core",
@@ -148,6 +150,8 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
148
150
 
149
151
  const handlers = {
150
152
  setPreference: r.writeHandler(setPreferenceWrite),
153
+ unsubscribeAddress: r.writeHandler(unsubscribeAddressWrite),
154
+ unsubscribeUser: r.writeHandler(unsubscribeUserWrite),
151
155
  };
152
156
 
153
157
  const queries = {
@@ -0,0 +1,40 @@
1
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import * as z from "zod";
4
+ import { upsertAddressOptOut } from "../address-opt-out";
5
+
6
+ export const unsubscribeAddressWrite = defineWriteHandler({
7
+ name: "unsubscribeAddress",
8
+ schema: z.object({
9
+ addressHash: z.string().min(1),
10
+ notificationType: z.string().min(1),
11
+ channel: z.string().min(1),
12
+ }),
13
+ access: { roles: access.systemAdmin },
14
+ // Only reachable via createUnsubscribeRoute's dispatchSystemWrite, once
15
+ // verify() has already proven the address-token signature — not a surface
16
+ // for the agent tool-picker to offer a user directly.
17
+ agent: { expose: false },
18
+ description:
19
+ "Records a no-account address opt-out for one notificationType/channel combination; dispatched by the signed unsubscribe route, not meant for UI callers.",
20
+ handler: async (event, ctx) => {
21
+ const { addressHash, notificationType, channel } = event.payload;
22
+ const { tenantId } = event.user;
23
+
24
+ if (!ctx.systemDb) {
25
+ throw new InternalError({
26
+ message: "unsubscribeAddress: ctx.systemDb missing on a system-scoped handler",
27
+ });
28
+ }
29
+ const db = ctx.systemDb.assertTenantMatch(tenantId);
30
+
31
+ const result = await upsertAddressOptOut(db, event.user, {
32
+ tenantId,
33
+ addressHash,
34
+ notificationType,
35
+ channel,
36
+ });
37
+ if (!result.isSuccess) return result;
38
+ return { isSuccess: true, data: { notificationType, channel } };
39
+ },
40
+ });
@@ -0,0 +1,43 @@
1
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import * as z from "zod";
4
+ import { upsertPreference } from "../upsert-preference";
5
+
6
+ export const unsubscribeUserWrite = defineWriteHandler({
7
+ name: "unsubscribeUser",
8
+ schema: z.object({
9
+ userId: z.string().min(1),
10
+ notificationType: z.string().min(1),
11
+ channel: z.string().min(1),
12
+ }),
13
+ access: { roles: access.systemAdmin },
14
+ // Only reachable via createUnsubscribeRoute's dispatchSystemWrite, once
15
+ // verify() has already proven the user-token signature — not a surface for
16
+ // the agent tool-picker to offer a user directly.
17
+ agent: { expose: false },
18
+ description:
19
+ "Disables one notification type and channel combination for the userId carried in a verified unsubscribe token; dispatched by the signed unsubscribe route, not meant for UI callers.",
20
+ handler: async (event, ctx) => {
21
+ // userId comes from the payload, not event.user.id — the caller here is
22
+ // the system user dispatchSystemWrite runs as, not the user unsubscribing.
23
+ const { userId, notificationType, channel } = event.payload;
24
+ const { tenantId } = event.user;
25
+
26
+ if (!ctx.systemDb) {
27
+ throw new InternalError({
28
+ message: "unsubscribeUser: ctx.systemDb missing on a system-scoped handler",
29
+ });
30
+ }
31
+ const db = ctx.systemDb.assertTenantMatch(tenantId);
32
+
33
+ const result = await upsertPreference(db, event.user, {
34
+ tenantId,
35
+ userId,
36
+ notificationType,
37
+ channel,
38
+ enabled: false,
39
+ });
40
+ if (!result.isSuccess) return result;
41
+ return { isSuccess: true, data: { notificationType, channel } };
42
+ },
43
+ });
@@ -4,6 +4,7 @@ export {
4
4
  DELIVERY_CHANNEL_EXTENSION,
5
5
  DELIVERY_FEATURE,
6
6
  DELIVERY_LOG_SCREEN_ID,
7
+ DELIVERY_UNSUBSCRIBE_PATH,
7
8
  DeliveryErrors,
8
9
  DeliveryHandlers,
9
10
  DeliveryJobs,
@@ -5,8 +5,14 @@ export const DELIVERY_FEATURE = "delivery" as const;
5
5
 
6
6
  export const DeliveryHandlers = {
7
7
  setPreference: "delivery:write:set-preference",
8
+ unsubscribeAddress: "delivery:write:unsubscribe-address",
9
+ unsubscribeUser: "delivery:write:unsubscribe-user",
8
10
  } as const;
9
11
 
12
+ // Fixed so links mailed out today keep working — the unsubscribe route is
13
+ // mounted at this exact path via `extraRoutes: [createUnsubscribeRoute(...)]`.
14
+ export const DELIVERY_UNSUBSCRIBE_PATH = "/api/delivery/unsubscribe" as const;
15
+
10
16
  export const DeliveryQueries = {
11
17
  log: "delivery:query:log",
12
18
  preferences: "delivery:query:preferences",