@cosmicdrift/kumiko-bundled-features 0.320.0 → 0.322.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/agent-tools/__tests__/tool-catalog.test.ts +16 -1
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +130 -3
- package/src/agent-tools/agent-manifest.ts +13 -3
- package/src/agent-tools/tool-dispatch.ts +17 -12
- package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
- package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
- package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
- package/src/billing-foundation/changes.json +14 -0
- package/src/billing-foundation/checkout-core.ts +8 -4
- package/src/billing-foundation/constants.ts +6 -0
- package/src/billing-foundation/feature.ts +37 -2
- package/src/billing-foundation/handlers/process-event.write.ts +112 -106
- package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
- package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
- package/src/billing-foundation/i18n.ts +9 -0
- package/src/billing-foundation/index.ts +1 -0
- package/src/billing-foundation/plan-catalog.ts +22 -5
- package/src/billing-foundation/types.ts +27 -0
- package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
- package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
- package/src/channel-email/__tests__/email-channel.test.ts +92 -0
- package/src/channel-email/changes.json +9 -1
- package/src/channel-email/email-channel.ts +31 -6
- package/src/crypto-shredding/handlers/forget-subject.write.ts +3 -1
- package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
- package/src/delivery/changes.json +7 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
- package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
- package/src/delivery/index.ts +2 -1
- package/src/delivery/public-names.ts +1 -1
- package/src/delivery/unsubscribe.ts +167 -89
- package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
- package/src/step-dispatcher/__tests__/webhook-runner.test.ts +187 -35
- package/src/step-dispatcher/changes.json +7 -0
- package/src/step-dispatcher/feature.ts +48 -15
- package/src/step-dispatcher/index.ts +3 -1
- package/src/step-dispatcher/webhook-runner.ts +59 -19
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
- package/src/subscription-stripe/feature.ts +5 -1
- package/src/subscription-stripe/plugin-methods.ts +53 -0
- package/src/subscription-stripe/verify-webhook.ts +52 -28
- package/src/tenant/__tests__/members-facet-i18n.test.ts +3 -2
- package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
- package/src/tenant-handover/changes.json +7 -0
- package/src/tenant-handover/handlers/claim.write.ts +1 -0
- package/src/tenant-handover/move-entity-graph.ts +30 -44
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +6 -10
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +3 -1
|
@@ -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
|
|
4
|
-
ChannelMessage,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
writeFailure,
|
|
29
29
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
30
30
|
import { append } from "@cosmicdrift/kumiko-framework/event-store";
|
|
31
|
+
import { assertIrreversibleOperationAllowed } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
31
32
|
import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
|
|
32
33
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
33
34
|
import * as z from "zod";
|
|
@@ -267,7 +268,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
267
268
|
"Irreversibly crypto-shreds one user, tenant or record subject by erasing its encryption key, nulling its blind indexes, purging its search documents and closing the user's login, for supervisory-authority requests and operator recovery outside the automated Art. 17 cleanup pipeline.",
|
|
268
269
|
// Erasing the subject key is irreversible: there is no undo, so an agent must
|
|
269
270
|
// not be able to reach it at all.
|
|
270
|
-
agent: { expose: false },
|
|
271
|
+
agent: { expose: false, risk: "high" },
|
|
271
272
|
escapeHatch: {
|
|
272
273
|
reason:
|
|
273
274
|
"denial audit append names the prober's own tenant stream on the outside-transaction db; " +
|
|
@@ -276,6 +277,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
276
277
|
"lifecycle update and PAT revoke run on the SYSTEM user stream.",
|
|
277
278
|
},
|
|
278
279
|
handler: async (event, ctx) => {
|
|
280
|
+
assertIrreversibleOperationAllowed("forget-subject key erase");
|
|
279
281
|
const kms = configuredPiiSubjectKms();
|
|
280
282
|
if (!kms) {
|
|
281
283
|
return writeFailure(
|
|
@@ -64,7 +64,7 @@ import {
|
|
|
64
64
|
import { createDeliveryTestContext } from "../testing";
|
|
65
65
|
import type { DeliveryService } from "../types";
|
|
66
66
|
import {
|
|
67
|
-
|
|
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: [
|
|
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("
|
|
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
|
|
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
|
|
850
|
-
expect(
|
|
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
|
|
855
|
-
expect(
|
|
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
|
|
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
|
|
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
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
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",
|
package/src/delivery/feature.ts
CHANGED
|
@@ -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 `
|
|
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
|
|
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
|
|
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 },
|
package/src/delivery/index.ts
CHANGED
|
@@ -43,7 +43,8 @@ export {
|
|
|
43
43
|
} from "./types";
|
|
44
44
|
export {
|
|
45
45
|
type AddressUnsubscribeTokenPayload,
|
|
46
|
-
|
|
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: [
|
|
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 = {
|