@cosmicdrift/kumiko-bundled-features 0.320.0 → 0.321.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 (42) hide show
  1. package/package.json +9 -9
  2. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
  3. package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
  4. package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
  5. package/src/billing-foundation/changes.json +14 -0
  6. package/src/billing-foundation/checkout-core.ts +8 -4
  7. package/src/billing-foundation/constants.ts +6 -0
  8. package/src/billing-foundation/feature.ts +37 -2
  9. package/src/billing-foundation/handlers/process-event.write.ts +112 -106
  10. package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
  11. package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
  12. package/src/billing-foundation/i18n.ts +9 -0
  13. package/src/billing-foundation/index.ts +1 -0
  14. package/src/billing-foundation/plan-catalog.ts +22 -5
  15. package/src/billing-foundation/types.ts +27 -0
  16. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
  17. package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
  18. package/src/channel-email/__tests__/email-channel.test.ts +92 -0
  19. package/src/channel-email/changes.json +9 -1
  20. package/src/channel-email/email-channel.ts +31 -6
  21. package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
  22. package/src/delivery/changes.json +7 -0
  23. package/src/delivery/feature.ts +1 -1
  24. package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
  25. package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
  26. package/src/delivery/index.ts +2 -1
  27. package/src/delivery/public-names.ts +1 -1
  28. package/src/delivery/unsubscribe.ts +167 -89
  29. package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
  30. package/src/step-dispatcher/__tests__/webhook-runner.test.ts +187 -35
  31. package/src/step-dispatcher/changes.json +7 -0
  32. package/src/step-dispatcher/feature.ts +48 -15
  33. package/src/step-dispatcher/index.ts +3 -1
  34. package/src/step-dispatcher/webhook-runner.ts +59 -19
  35. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
  36. package/src/subscription-stripe/feature.ts +5 -1
  37. package/src/subscription-stripe/plugin-methods.ts +53 -0
  38. package/src/subscription-stripe/verify-webhook.ts +52 -28
  39. package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
  40. package/src/tenant-handover/changes.json +7 -0
  41. package/src/tenant-handover/handlers/claim.write.ts +1 -0
  42. package/src/tenant-handover/move-entity-graph.ts +30 -44
@@ -20,6 +20,7 @@ import {
20
20
  BillingPlanActions,
21
21
  SubscriptionFoundationHandlers,
22
22
  SubscriptionFoundationQueries,
23
+ SubscriptionStatuses,
23
24
  } from "../constants";
24
25
  import type { BillingPlansResult, BillingPlanView } from "../types";
25
26
 
@@ -241,6 +242,11 @@ export function BillingPlansPanel(_props: ExtensionSectionProps): ReactNode {
241
242
  {t(mutationError.i18nKey, mutationError.i18nParams)}
242
243
  </Banner>
243
244
  )}
245
+ {result.subscription?.status === SubscriptionStatuses.pastDue && (
246
+ <Banner variant="warning" testId="billing-plans-panel-past-due">
247
+ {t("billing-foundation.plans.pastDue")}
248
+ </Banner>
249
+ )}
244
250
  {result.subscription !== null &&
245
251
  !result.subscription.terminal &&
246
252
  result.subscription.cancelAt != null && (
@@ -249,7 +255,8 @@ export function BillingPlansPanel(_props: ExtensionSectionProps): ReactNode {
249
255
  date: toInstant(result.subscription.cancelAt).toLocaleString(locale, {
250
256
  dateStyle: "medium",
251
257
  }),
252
- })}
258
+ })}{" "}
259
+ {t("billing-foundation.plans.switchRequiresReactivation")}
253
260
  </Banner>
254
261
  )}
255
262
  <PlanGrid testId="billing-plans-grid">
@@ -100,3 +100,95 @@ describe("email channel envelope", () => {
100
100
  expect(sent.headers).toBeUndefined();
101
101
  });
102
102
  });
103
+
104
+ describe("email channel List-Unsubscribe headers", () => {
105
+ test("unsubscribeUrl on the framework route sets both headers", async () => {
106
+ const transport = createInMemoryTransport();
107
+ const unsubscribeUrl = "https://app.example/api/delivery/unsubscribe?token=abc";
108
+ const message: ChannelMessage = {
109
+ notificationType: "x",
110
+ title: "t",
111
+ body: "b",
112
+ data: { subject: "t", body: "b", unsubscribeUrl },
113
+ };
114
+ await channelWith(transport).send("mieter@example.com", message, ctx, rendered);
115
+
116
+ const [sent] = transport.sent;
117
+ if (!sent) throw new Error("expected a sent mail");
118
+ expect(sent.headers).toEqual({
119
+ "List-Unsubscribe": `<${unsubscribeUrl}>`,
120
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
121
+ });
122
+ });
123
+
124
+ test("unsubscribeUrl on a foreign path sets no headers", async () => {
125
+ const transport = createInMemoryTransport();
126
+ const message: ChannelMessage = {
127
+ notificationType: "x",
128
+ title: "t",
129
+ body: "b",
130
+ data: {
131
+ subject: "t",
132
+ body: "b",
133
+ unsubscribeUrl: "https://app.example/subscribe/unsubscribe?token=abc",
134
+ },
135
+ };
136
+ await channelWith(transport).send("mieter@example.com", message, ctx, rendered);
137
+
138
+ const [sent] = transport.sent;
139
+ if (!sent) throw new Error("expected a sent mail");
140
+ expect(sent.headers).toBeUndefined();
141
+ });
142
+
143
+ test("an unparseable unsubscribeUrl sets no headers", async () => {
144
+ const transport = createInMemoryTransport();
145
+ const message: ChannelMessage = {
146
+ notificationType: "x",
147
+ title: "t",
148
+ body: "b",
149
+ data: { subject: "t", body: "b", unsubscribeUrl: "not a url" },
150
+ };
151
+ await channelWith(transport).send("mieter@example.com", message, ctx, rendered);
152
+
153
+ const [sent] = transport.sent;
154
+ if (!sent) throw new Error("expected a sent mail");
155
+ expect(sent.headers).toBeUndefined();
156
+ });
157
+
158
+ test("no unsubscribeUrl sets no headers", async () => {
159
+ const transport = createInMemoryTransport();
160
+ const message: ChannelMessage = {
161
+ notificationType: "x",
162
+ title: "t",
163
+ body: "b",
164
+ data: { subject: "t", body: "b" },
165
+ };
166
+ await channelWith(transport).send("mieter@example.com", message, ctx, rendered);
167
+
168
+ const [sent] = transport.sent;
169
+ if (!sent) throw new Error("expected a sent mail");
170
+ expect(sent.headers).toBeUndefined();
171
+ });
172
+
173
+ test('an explicit data.headers["List-Unsubscribe"] wins over the auto header', async () => {
174
+ const transport = createInMemoryTransport();
175
+ const unsubscribeUrl = "https://app.example/api/delivery/unsubscribe?token=abc";
176
+ const message: ChannelMessage = {
177
+ notificationType: "x",
178
+ title: "t",
179
+ body: "b",
180
+ data: {
181
+ subject: "t",
182
+ body: "b",
183
+ unsubscribeUrl,
184
+ headers: { "List-Unsubscribe": "<mailto:override@example.com>" },
185
+ },
186
+ };
187
+ await channelWith(transport).send("mieter@example.com", message, ctx, rendered);
188
+
189
+ const [sent] = transport.sent;
190
+ if (!sent) throw new Error("expected a sent mail");
191
+ expect(sent.headers?.["List-Unsubscribe"]).toBe("<mailto:override@example.com>");
192
+ expect(sent.headers?.["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click");
193
+ });
194
+ });
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.321.0",
4
+ "type": "improvement",
5
+ "title": "Automatic List-Unsubscribe / List-Unsubscribe-Post headers for messages that carry a framework unsubscribeUrl",
6
+ "detail": "`createEmailChannel`'s `send()` now sets `List-Unsubscribe: <url>` and\n`List-Unsubscribe-Post: List-Unsubscribe=One-Click` whenever the\nnotification's `data.unsubscribeUrl` is an `http(s)` URL whose path is\nthe delivery feature's `DELIVERY_UNSUBSCRIBE_PATH` — only that route can\nhonor a one-click POST. An explicit `data.headers[\"List-Unsubscribe\"]`\n(or `-Post`) still wins over the automatic value. Apps whose\n`unsubscribeUrl` points somewhere else (their own page, a foreign path)\nget no automatic headers.",
7
+ "migration": "No action needed for apps already using the framework's unsubscribe\nroute via `data.unsubscribeUrl`. Apps with a GET-only unsubscribe page of\ntheir own keep getting no `List-Unsubscribe-Post` header, since a\none-click POST there would 404 or no-op."
8
+ }
9
+ ]
@@ -1,10 +1,12 @@
1
1
  import type { DbRow } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
- import type {
4
- ChannelMessage,
5
- DeliveryChannel,
6
- NotificationRenderer,
7
- RenderedMessage,
3
+ import {
4
+ type ChannelMessage,
5
+ DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE,
6
+ DELIVERY_UNSUBSCRIBE_PATH,
7
+ type DeliveryChannel,
8
+ type NotificationRenderer,
9
+ type RenderedMessage,
8
10
  } from "../delivery";
9
11
  import { guardEmailMessage } from "./pii-guard";
10
12
  import type { EmailTransport } from "./types";
@@ -24,6 +26,26 @@ function stringHeaders(raw: unknown): Readonly<Record<string, string>> | undefin
24
26
  return Object.keys(out).length > 0 ? out : undefined;
25
27
  }
26
28
 
29
+ // Only the framework's own unsubscribe route can honor a one-click POST.
30
+ function listUnsubscribeHeaders(
31
+ data: Readonly<Record<string, unknown>> | undefined,
32
+ ): Readonly<Record<string, string>> | undefined {
33
+ const rawUrl = data?.["unsubscribeUrl"];
34
+ if (typeof rawUrl !== "string") return undefined;
35
+ let url: URL;
36
+ try {
37
+ url = new URL(rawUrl);
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
42
+ if (url.pathname !== DELIVERY_UNSUBSCRIBE_PATH) return undefined;
43
+ return {
44
+ "List-Unsubscribe": `<${url.toString()}>`,
45
+ "List-Unsubscribe-Post": DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE,
46
+ };
47
+ }
48
+
27
49
  function emailEnvelopeFrom(data: Readonly<Record<string, unknown>> | undefined): {
28
50
  from?: string;
29
51
  replyTo?: string;
@@ -32,7 +54,10 @@ function emailEnvelopeFrom(data: Readonly<Record<string, unknown>> | undefined):
32
54
  if (!data) return {};
33
55
  const from = typeof data["from"] === "string" ? data["from"] : undefined;
34
56
  const replyTo = typeof data["replyTo"] === "string" ? data["replyTo"] : undefined;
35
- const headers = stringHeaders(data["headers"]);
57
+ const autoHeaders = listUnsubscribeHeaders(data);
58
+ const explicitHeaders = stringHeaders(data["headers"]);
59
+ const headers =
60
+ autoHeaders || explicitHeaders ? { ...autoHeaders, ...explicitHeaders } : undefined;
36
61
  return { ...(from && { from }), ...(replyTo && { replyTo }), ...(headers && { headers }) };
37
62
  }
38
63
 
@@ -64,7 +64,7 @@ import {
64
64
  import { createDeliveryTestContext } from "../testing";
65
65
  import type { DeliveryService } from "../types";
66
66
  import {
67
- createUnsubscribeRoute,
67
+ createUnsubscribeRoutes,
68
68
  signAddressUnsubscribeToken,
69
69
  signUnsubscribeToken,
70
70
  } from "../unsubscribe";
@@ -76,6 +76,16 @@ let db: DbConnection;
76
76
  let deliveryService: DeliveryService;
77
77
  const UNSUBSCRIBE_SECRET = "test-stack-unsubscribe-secret-32-chars-min!!";
78
78
 
79
+ // Mirrors a confirmation-page form submit / RFC-8058 one-click POST: token in
80
+ // a form-urlencoded body, same encoding both senders use.
81
+ function postUnsubscribe(token: string) {
82
+ return stack.app.request(DELIVERY_UNSUBSCRIBE_PATH, {
83
+ method: "POST",
84
+ headers: { "content-type": "application/x-www-form-urlencoded" },
85
+ body: `token=${encodeURIComponent(token)}`,
86
+ });
87
+ }
88
+
79
89
  // Email test infrastructure
80
90
  const emailTransport = createInMemoryTransport();
81
91
  const testEmail = (userId: string | number) => `user-${userId}@test.com`;
@@ -373,7 +383,7 @@ beforeAll(async () => {
373
383
  deliveryService = ctx.deliveryService;
374
384
  return ctx;
375
385
  },
376
- extraRoutes: [createUnsubscribeRoute({ secret: UNSUBSCRIBE_SECRET })],
386
+ extraRoutes: [...createUnsubscribeRoutes({ secret: UNSUBSCRIBE_SECRET })],
377
387
  });
378
388
  db = stack.db;
379
389
 
@@ -793,7 +803,7 @@ describe("flow 6: user preferences", () => {
793
803
  // --- Flow 7: Unsubscribe endpoint ---
794
804
 
795
805
  describe("flow 7: unsubscribe endpoint", () => {
796
- test("signed unsubscribe token disables preference", async () => {
806
+ test("GET renders a confirmation page and writes nothing", async () => {
797
807
  const token = await signUnsubscribeToken(
798
808
  {
799
809
  userId: user2.id,
@@ -806,10 +816,37 @@ describe("flow 7: unsubscribe endpoint", () => {
806
816
 
807
817
  const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
808
818
  expect(res.status).toBe(200);
819
+ const html = await res.text();
820
+ expect(html).toContain("<form");
821
+ expect(html).toContain('method="post"');
822
+
823
+ const prefs = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
824
+ DeliveryQueries.preferences,
825
+ {},
826
+ user2,
827
+ );
828
+ const pref = prefs.rows.find(
829
+ (r) => r["notificationType"] === "app:notify:announcement" && r["channel"] === "inApp",
830
+ );
831
+ expect(pref).toBeUndefined();
832
+ });
833
+
834
+ test("POST with a form-urlencoded body disables the preference", async () => {
835
+ const token = await signUnsubscribeToken(
836
+ {
837
+ userId: user2.id,
838
+ tenantId: user2.tenantId,
839
+ notificationType: "app:notify:announcement",
840
+ channel: "inApp",
841
+ },
842
+ UNSUBSCRIBE_SECRET,
843
+ );
844
+
845
+ const res = await postUnsubscribe(token);
846
+ expect(res.status).toBe(200);
809
847
  const text = await res.text();
810
848
  expect(text).toContain("unsubscribed");
811
849
 
812
- // Verify preference was created
813
850
  const prefs = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
814
851
  DeliveryQueries.preferences,
815
852
  {},
@@ -822,6 +859,36 @@ describe("flow 7: unsubscribe endpoint", () => {
822
859
  expect(pref?.["enabled"]).toBe(false);
823
860
  });
824
861
 
862
+ test("RFC 8058 one-click POST (token in query, List-Unsubscribe=One-Click body) disables the preference", async () => {
863
+ const token = await signUnsubscribeToken(
864
+ {
865
+ userId: user2.id,
866
+ tenantId: user2.tenantId,
867
+ notificationType: "app:notify:one-click",
868
+ channel: "inApp",
869
+ },
870
+ UNSUBSCRIBE_SECRET,
871
+ );
872
+
873
+ const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`, {
874
+ method: "POST",
875
+ headers: { "content-type": "application/x-www-form-urlencoded" },
876
+ body: "List-Unsubscribe=One-Click",
877
+ });
878
+ expect(res.status).toBe(200);
879
+
880
+ const prefs = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
881
+ DeliveryQueries.preferences,
882
+ {},
883
+ user2,
884
+ );
885
+ const pref = prefs.rows.find(
886
+ (r) => r["notificationType"] === "app:notify:one-click" && r["channel"] === "inApp",
887
+ );
888
+ expect(pref).toBeDefined();
889
+ expect(pref?.["enabled"]).toBe(false);
890
+ });
891
+
825
892
  test("preference row belongs to the token's userId, not the dispatching system user", async () => {
826
893
  const token = await signUnsubscribeToken(
827
894
  {
@@ -833,7 +900,7 @@ describe("flow 7: unsubscribe endpoint", () => {
833
900
  UNSUBSCRIBE_SECRET,
834
901
  );
835
902
 
836
- const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
903
+ const res = await postUnsubscribe(token);
837
904
  expect(res.status).toBe(200);
838
905
 
839
906
  const rows = await selectMany(db, notificationPreferencesTable, {
@@ -845,14 +912,24 @@ describe("flow 7: unsubscribe endpoint", () => {
845
912
  expect(rows[0]?.["enabled"]).toBe(false);
846
913
  });
847
914
 
848
- test("invalid token returns 400", async () => {
849
- const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=invalid-jwt-token`);
850
- expect(res.status).toBe(400);
915
+ test("invalid token returns 400 for both GET and POST", async () => {
916
+ const getRes = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=invalid-jwt-token`);
917
+ expect(getRes.status).toBe(400);
918
+ const getBody = (await getRes.json()) as { error?: { code?: string } };
919
+ expect(getBody.error?.code).toBe("unsubscribe_token_invalid");
920
+
921
+ const postRes = await postUnsubscribe("invalid-jwt-token");
922
+ expect(postRes.status).toBe(400);
923
+ const postBody = (await postRes.json()) as { error?: { code?: string } };
924
+ expect(postBody.error?.code).toBe("unsubscribe_token_invalid");
851
925
  });
852
926
 
853
- test("missing token returns 400", async () => {
854
- const res = await stack.app.request(DELIVERY_UNSUBSCRIBE_PATH);
855
- expect(res.status).toBe(400);
927
+ test("missing token returns 400 for both GET and POST", async () => {
928
+ const getRes = await stack.app.request(DELIVERY_UNSUBSCRIBE_PATH);
929
+ expect(getRes.status).toBe(400);
930
+
931
+ const postRes = await stack.app.request(DELIVERY_UNSUBSCRIBE_PATH, { method: "POST" });
932
+ expect(postRes.status).toBe(400);
856
933
  });
857
934
 
858
935
  test("normal user cannot dispatch the unsubscribe write handlers directly", async () => {
@@ -895,7 +972,7 @@ describe("flow 7: unsubscribe endpoint", () => {
895
972
  .setIssuedAt()
896
973
  .sign(new TextEncoder().encode(UNSUBSCRIBE_SECRET));
897
974
 
898
- const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${tamperedIssuer}`);
975
+ const res = await postUnsubscribe(tamperedIssuer);
899
976
  expect(res.status).toBe(400);
900
977
  const body = (await res.json()) as { error?: { code?: string; message?: string } };
901
978
  expect(body.error?.code).toBe("unsubscribe_token_invalid");
@@ -912,7 +989,7 @@ describe("flow 7: unsubscribe endpoint", () => {
912
989
 
913
990
  test("a real stack session JWT is rejected at the unsubscribe route", async () => {
914
991
  const sessionToken = await stack.jwt.sign(user1);
915
- const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${sessionToken}`);
992
+ const res = await postUnsubscribe(sessionToken);
916
993
  expect(res.status).toBe(400);
917
994
  const body = (await res.json()) as { error?: { code?: string } };
918
995
  expect(body.error?.code).toBe("unsubscribe_token_invalid");
@@ -1573,11 +1650,10 @@ describe("flow 16: repeated unsubscribe clicks are idempotent", () => {
1573
1650
  UNSUBSCRIBE_SECRET,
1574
1651
  );
1575
1652
 
1576
- const url = `${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`;
1577
1653
  const results = await Promise.all([
1578
- stack.app.request(url),
1579
- stack.app.request(url),
1580
- stack.app.request(url),
1654
+ postUnsubscribe(token),
1655
+ postUnsubscribe(token),
1656
+ postUnsubscribe(token),
1581
1657
  ]);
1582
1658
 
1583
1659
  // All three requests complete with 200 — no duplicate-key crashes
@@ -1964,12 +2040,12 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
1964
2040
  },
1965
2041
  UNSUBSCRIBE_SECRET,
1966
2042
  );
1967
- const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
2043
+ const res = await postUnsubscribe(token);
1968
2044
  expect(res.status).toBe(200);
1969
2045
  expect(await res.text()).toContain("unsubscribed");
1970
2046
 
1971
2047
  // Clicking twice must stay a no-op (one row, no crash).
1972
- const res2 = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
2048
+ const res2 = await postUnsubscribe(token);
1973
2049
  expect(res2.status).toBe(200);
1974
2050
  const optOutRows = await selectMany(db, notificationAddressOptOutsTable, {
1975
2051
  notificationType: "app:notify:address-unsub-19a",
@@ -2035,7 +2111,7 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
2035
2111
  FOREIGN_JWT_SECRET,
2036
2112
  );
2037
2113
 
2038
- const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${forgedToken}`);
2114
+ const res = await postUnsubscribe(forgedToken);
2039
2115
  expect(res.status).toBe(400);
2040
2116
 
2041
2117
  const rows = await selectMany(db, notificationAddressOptOutsTable, {
@@ -2077,7 +2153,7 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
2077
2153
  },
2078
2154
  UNSUBSCRIBE_SECRET,
2079
2155
  );
2080
- const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
2156
+ const res = await postUnsubscribe(token);
2081
2157
  expect(res.status).toBe(200);
2082
2158
 
2083
2159
  await deliveryService.notify(
@@ -2125,7 +2201,7 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
2125
2201
  { tenantId: user1.tenantId, address, notificationType, channel: "email" },
2126
2202
  UNSUBSCRIBE_SECRET,
2127
2203
  );
2128
- const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
2204
+ const res = await postUnsubscribe(token);
2129
2205
  expect(res.status).toBe(200);
2130
2206
 
2131
2207
  emailTransport.sent.length = 0;
@@ -2175,11 +2251,10 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
2175
2251
  UNSUBSCRIBE_SECRET,
2176
2252
  );
2177
2253
 
2178
- const url = `${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`;
2179
2254
  const results = await Promise.all([
2180
- stack.app.request(url),
2181
- stack.app.request(url),
2182
- stack.app.request(url),
2255
+ postUnsubscribe(token),
2256
+ postUnsubscribe(token),
2257
+ postUnsubscribe(token),
2183
2258
  ]);
2184
2259
 
2185
2260
  for (const res of results) {
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.321.0",
4
+ "type": "breaking",
5
+ "title": "Unsubscribe route splits into a non-writing GET confirmation page and a writing POST (RFC 8058)",
6
+ "detail": "`createUnsubscribeRoute({ secret })`, which mounted a single `GET` route\nthat wrote the opt-out as a side effect of the request, is replaced by\n`createUnsubscribeRoutes({ secret })`, which returns both routes for the\nsame path: `GET` renders a confirmation page (`<form method=\"post\">`) and\nperforms no write; `POST` performs the write, reading the token from a\nform-urlencoded body first, then the query string as a fallback (so an\nRFC-8058 one-click client that POSTs `token=` only in the URL still works).\nToken verification and error mapping (`400 unsubscribe_token_invalid`) are\nunchanged and shared between both routes.",
7
+ "migration": "Replace `extraRoutes: [createUnsubscribeRoute({ secret })]` with\n`extraRoutes: [...createUnsubscribeRoutes({ secret })]`.\n\nUnsubscribe links already mailed out before this upgrade now show a\nconfirmation page on click instead of unsubscribing immediately — the\nrecipient must submit the form (or a one-click mail client must POST) to\ncomplete the opt-out. If the app rendered its own confirmation page in\nfront of the old GET link, it can be removed; the framework route now\ncovers that step."
8
+ },
2
9
  {
3
10
  "version": "0.315.0",
4
11
  "type": "fix",
@@ -44,7 +44,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
44
44
  const resolvedAccess = options?.access ?? access.admin;
45
45
  return defineFeature("delivery", (r) => {
46
46
  r.describe(
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.",
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 `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe`: `GET ?token=` renders a confirmation page (no write), `POST` performs the opt-out (RFC 8058 one-click, token from the form body or query) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at this route.",
48
48
  );
49
49
  r.uiHints({
50
50
  displayLabel: "Notifications \u00b7 Dispatch Core",
@@ -11,7 +11,7 @@ export const unsubscribeAddressWrite = defineWriteHandler({
11
11
  channel: z.string().min(1),
12
12
  }),
13
13
  access: { roles: access.systemAdmin },
14
- // Only reachable via createUnsubscribeRoute's dispatchSystemWrite, once
14
+ // Only reachable via createUnsubscribeRoutes' POST dispatchSystemWrite, once
15
15
  // verify() has already proven the address-token signature — not a surface
16
16
  // for the agent tool-picker to offer a user directly.
17
17
  agent: { expose: false },
@@ -11,7 +11,7 @@ export const unsubscribeUserWrite = defineWriteHandler({
11
11
  channel: z.string().min(1),
12
12
  }),
13
13
  access: { roles: access.systemAdmin },
14
- // Only reachable via createUnsubscribeRoute's dispatchSystemWrite, once
14
+ // Only reachable via createUnsubscribeRoutes' POST dispatchSystemWrite, once
15
15
  // verify() has already proven the user-token signature — not a surface for
16
16
  // the agent tool-picker to offer a user directly.
17
17
  agent: { expose: false },
@@ -43,7 +43,8 @@ export {
43
43
  } from "./types";
44
44
  export {
45
45
  type AddressUnsubscribeTokenPayload,
46
- createUnsubscribeRoute,
46
+ createUnsubscribeRoutes,
47
+ DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE,
47
48
  signAddressUnsubscribeToken,
48
49
  signUnsubscribeToken,
49
50
  type UnsubscribeRouteOptions,
@@ -10,7 +10,7 @@ export const DeliveryHandlers = {
10
10
  } as const;
11
11
 
12
12
  // Fixed so links mailed out today keep working — the unsubscribe route is
13
- // mounted at this exact path via `extraRoutes: [createUnsubscribeRoute(...)]`.
13
+ // mounted at this exact path via `extraRoutes: [...createUnsubscribeRoutes(...)]`.
14
14
  export const DELIVERY_UNSUBSCRIBE_PATH = "/api/delivery/unsubscribe" as const;
15
15
 
16
16
  export const DeliveryQueries = {