@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.
Files changed (50) hide show
  1. package/package.json +9 -9
  2. package/src/agent-tools/__tests__/tool-catalog.test.ts +16 -1
  3. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +130 -3
  4. package/src/agent-tools/agent-manifest.ts +13 -3
  5. package/src/agent-tools/tool-dispatch.ts +17 -12
  6. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
  7. package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
  8. package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
  9. package/src/billing-foundation/changes.json +14 -0
  10. package/src/billing-foundation/checkout-core.ts +8 -4
  11. package/src/billing-foundation/constants.ts +6 -0
  12. package/src/billing-foundation/feature.ts +37 -2
  13. package/src/billing-foundation/handlers/process-event.write.ts +112 -106
  14. package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
  15. package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
  16. package/src/billing-foundation/i18n.ts +9 -0
  17. package/src/billing-foundation/index.ts +1 -0
  18. package/src/billing-foundation/plan-catalog.ts +22 -5
  19. package/src/billing-foundation/types.ts +27 -0
  20. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
  21. package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
  22. package/src/channel-email/__tests__/email-channel.test.ts +92 -0
  23. package/src/channel-email/changes.json +9 -1
  24. package/src/channel-email/email-channel.ts +31 -6
  25. package/src/crypto-shredding/handlers/forget-subject.write.ts +3 -1
  26. package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
  27. package/src/delivery/changes.json +7 -0
  28. package/src/delivery/feature.ts +1 -1
  29. package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
  30. package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
  31. package/src/delivery/index.ts +2 -1
  32. package/src/delivery/public-names.ts +1 -1
  33. package/src/delivery/unsubscribe.ts +167 -89
  34. package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
  35. package/src/step-dispatcher/__tests__/webhook-runner.test.ts +187 -35
  36. package/src/step-dispatcher/changes.json +7 -0
  37. package/src/step-dispatcher/feature.ts +48 -15
  38. package/src/step-dispatcher/index.ts +3 -1
  39. package/src/step-dispatcher/webhook-runner.ts +59 -19
  40. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
  41. package/src/subscription-stripe/feature.ts +5 -1
  42. package/src/subscription-stripe/plugin-methods.ts +53 -0
  43. package/src/subscription-stripe/verify-webhook.ts +52 -28
  44. package/src/tenant/__tests__/members-facet-i18n.test.ts +3 -2
  45. package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
  46. package/src/tenant-handover/changes.json +7 -0
  47. package/src/tenant-handover/handlers/claim.write.ts +1 -0
  48. package/src/tenant-handover/move-entity-graph.ts +30 -44
  49. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +6 -10
  50. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +3 -1
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  type ExtraRouteDefinition,
3
3
  ExtraRouteRejection,
4
+ type SignatureExtraRouteDeps,
5
+ type SignatureExtraRouteVerifyRequest,
4
6
  signatureRoute,
5
7
  } from "@cosmicdrift/kumiko-framework/api";
6
8
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
7
10
  import * as jose from "jose";
8
11
  import * as z from "zod";
9
12
  import { hashUnsubscribeAddress } from "./address-opt-out";
@@ -55,8 +58,8 @@ export type AddressUnsubscribeTokenPayload = {
55
58
  /**
56
59
  * `secret` must be a value dedicated to unsubscribe-token signing — do NOT
57
60
  * reuse the app's session `JWT_SECRET`. The app mounting `extraRoutes:
58
- * [createUnsubscribeRoute({ secret })]` signs outgoing links with the same
59
- * value via `signUnsubscribeToken` / `signAddressUnsubscribeToken`.
61
+ * [...createUnsubscribeRoutes({ secret })]` signs outgoing links with the
62
+ * same value via `signUnsubscribeToken` / `signAddressUnsubscribeToken`.
60
63
  */
61
64
  export type UnsubscribeRouteOptions = {
62
65
  readonly secret: string;
@@ -152,110 +155,185 @@ type VerifiedUnsubscribe =
152
155
  readonly channel: string;
153
156
  };
154
157
 
155
- export function createUnsubscribeRoute(options: UnsubscribeRouteOptions): ExtraRouteDefinition {
156
- assertUnsubscribeSecret(options.secret, "createUnsubscribeRoute");
157
- const encodedSecret = new TextEncoder().encode(options.secret);
158
+ // RFC 8058 fixed value — both the List-Unsubscribe-Post header and the body a
159
+ // one-click client POSTs.
160
+ export const DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE = "List-Unsubscribe=One-Click" as const;
158
161
 
159
- return signatureRoute<VerifiedUnsubscribe>({
160
- method: "GET",
161
- path: DELIVERY_UNSUBSCRIBE_PATH,
162
- entry: "signature",
163
- // Every throw here must become the same ExtraRouteRejection(400,
164
- // unsubscribe_token_invalid) — anything else falls through to the
165
- // framework's generic 401 extra_route_signature_invalid mapping, which
166
- // would leak jose's internal error text into the response body.
167
- verify: async ({ query }) => {
168
- try {
169
- const token = query["token"];
170
- if (!token) {
171
- throw new ExtraRouteRejection(
172
- 400,
173
- UNSUBSCRIBE_TOKEN_INVALID_BODY,
174
- "missing unsubscribe token",
175
- );
176
- }
162
+ const CONFIRMATION_PAGE_HEADERS = {
163
+ "Cache-Control": "no-store",
164
+ "Referrer-Policy": "no-referrer",
165
+ } as const;
177
166
 
178
- const { payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
179
- issuer: "kumiko:unsubscribe",
180
- });
167
+ // Every throw here must become the same ExtraRouteRejection(400,
168
+ // unsubscribe_token_invalid) — anything else falls through to the
169
+ // framework's generic 401 extra_route_signature_invalid mapping, which
170
+ // would leak jose's internal error text into the response body.
171
+ async function verifyUnsubscribeToken(
172
+ token: string | undefined,
173
+ encodedSecret: Uint8Array,
174
+ ): Promise<VerifiedUnsubscribe> {
175
+ try {
176
+ if (!token) {
177
+ throw new ExtraRouteRejection(
178
+ 400,
179
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
180
+ "missing unsubscribe token",
181
+ );
182
+ }
181
183
 
182
- const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
183
- if (addressAttempt.success) {
184
- const parsed = addressAttempt.data;
185
- if (parsed.sub !== parsed.addressHash) {
186
- throw new ExtraRouteRejection(
187
- 400,
188
- UNSUBSCRIBE_TOKEN_INVALID_BODY,
189
- "address token subject mismatch",
190
- );
191
- }
192
- // @cast-boundary engine-bridge — string post-zod → branded TenantId
193
- const tenantId = parsed.tenantId as TenantId;
194
- return {
195
- kind: "address",
196
- tenantId,
197
- addressHash: parsed.addressHash,
198
- notificationType: parsed.notificationType,
199
- channel: parsed.channel,
200
- };
201
- }
184
+ const { payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
185
+ issuer: "kumiko:unsubscribe",
186
+ });
187
+
188
+ const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
189
+ if (addressAttempt.success) {
190
+ const parsed = addressAttempt.data;
191
+ if (parsed.sub !== parsed.addressHash) {
192
+ throw new ExtraRouteRejection(
193
+ 400,
194
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
195
+ "address token subject mismatch",
196
+ );
197
+ }
198
+ // @cast-boundary engine-bridge — string post-zod → branded TenantId
199
+ const tenantId = parsed.tenantId as TenantId;
200
+ return {
201
+ kind: "address",
202
+ tenantId,
203
+ addressHash: parsed.addressHash,
204
+ notificationType: parsed.notificationType,
205
+ channel: parsed.channel,
206
+ };
207
+ }
208
+
209
+ const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
210
+ if (!userAttempt.success) {
211
+ throw new ExtraRouteRejection(
212
+ 400,
213
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
214
+ "unsubscribe token payload matched neither schema",
215
+ );
216
+ }
217
+ const parsed = userAttempt.data;
218
+ // @cast-boundary engine-bridge — string post-zod → branded TenantId
219
+ const tenantId = parsed.tenantId as TenantId;
220
+ return {
221
+ kind: "user",
222
+ tenantId,
223
+ userId: parsed.sub,
224
+ notificationType: parsed.notificationType,
225
+ channel: parsed.channel,
226
+ };
227
+ } catch (err) {
228
+ if (err instanceof ExtraRouteRejection) throw err;
229
+ throw new ExtraRouteRejection(
230
+ 400,
231
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
232
+ "unsubscribe token verification failed",
233
+ );
234
+ }
235
+ }
202
236
 
203
- const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
204
- if (!userAttempt.success) {
205
- throw new ExtraRouteRejection(
206
- 400,
207
- UNSUBSCRIBE_TOKEN_INVALID_BODY,
208
- "unsubscribe token payload matched neither schema",
209
- );
237
+ async function dispatchUnsubscribeWrite(
238
+ verified: VerifiedUnsubscribe,
239
+ deps: SignatureExtraRouteDeps,
240
+ ): Promise<boolean> {
241
+ const payload =
242
+ verified.kind === "address"
243
+ ? {
244
+ addressHash: verified.addressHash,
245
+ notificationType: verified.notificationType,
246
+ channel: verified.channel,
210
247
  }
211
- const parsed = userAttempt.data;
212
- // @cast-boundary engine-bridge — string post-zod → branded TenantId
213
- const tenantId = parsed.tenantId as TenantId;
214
- return {
215
- kind: "user",
216
- tenantId,
217
- userId: parsed.sub,
218
- notificationType: parsed.notificationType,
219
- channel: parsed.channel,
248
+ : {
249
+ userId: verified.userId,
250
+ notificationType: verified.notificationType,
251
+ channel: verified.channel,
220
252
  };
221
- } catch (err) {
222
- if (err instanceof ExtraRouteRejection) throw err;
253
+
254
+ const dispatched = await deps.dispatchSystemWrite({
255
+ handlerQn:
256
+ verified.kind === "address"
257
+ ? DeliveryHandlers.unsubscribeAddress
258
+ : DeliveryHandlers.unsubscribeUser,
259
+ tenantId: verified.tenantId,
260
+ payload,
261
+ });
262
+ return dispatched.isSuccess;
263
+ }
264
+
265
+ function confirmationPage(token: string): string {
266
+ return `<!doctype html>
267
+ <html>
268
+ <head>
269
+ <meta charset="utf-8">
270
+ <meta name="robots" content="noindex">
271
+ <title>Unsubscribe</title>
272
+ </head>
273
+ <body>
274
+ <p>Unsubscribe from these emails?</p>
275
+ <form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
276
+ <input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
277
+ <button type="submit">Unsubscribe</button>
278
+ </form>
279
+ </body>
280
+ </html>`;
281
+ }
282
+
283
+ // One-click clients put the token in the query string; only the
284
+ // confirmation-page form submits it in the body.
285
+ function tokenFromPostRequest(request: SignatureExtraRouteVerifyRequest): string | undefined {
286
+ const contentType = request.headers["content-type"];
287
+ if (contentType?.includes("application/x-www-form-urlencoded")) {
288
+ const fromBody = new URLSearchParams(request.rawBody).get("token");
289
+ if (fromBody) return fromBody;
290
+ }
291
+ return request.query["token"];
292
+ }
293
+
294
+ // GET renders a confirmation page without writing; POST performs the opt-out.
295
+ export function createUnsubscribeRoutes(
296
+ options: UnsubscribeRouteOptions,
297
+ ): readonly ExtraRouteDefinition[] {
298
+ assertUnsubscribeSecret(options.secret, "createUnsubscribeRoutes");
299
+ const encodedSecret = new TextEncoder().encode(options.secret);
300
+
301
+ const confirmRoute = signatureRoute<{ token: string }>({
302
+ method: "GET",
303
+ path: DELIVERY_UNSUBSCRIBE_PATH,
304
+ entry: "signature",
305
+ verify: async ({ query }) => {
306
+ const token = query["token"];
307
+ if (!token) {
223
308
  throw new ExtraRouteRejection(
224
309
  400,
225
310
  UNSUBSCRIBE_TOKEN_INVALID_BODY,
226
- "unsubscribe token verification failed",
311
+ "missing unsubscribe token",
227
312
  );
228
313
  }
314
+ await verifyUnsubscribeToken(token, encodedSecret);
315
+ return { token };
229
316
  },
230
- handler: async (c, verified, deps) => {
231
- const payload =
232
- verified.kind === "address"
233
- ? {
234
- addressHash: verified.addressHash,
235
- notificationType: verified.notificationType,
236
- channel: verified.channel,
237
- }
238
- : {
239
- userId: verified.userId,
240
- notificationType: verified.notificationType,
241
- channel: verified.channel,
242
- };
317
+ handler: async (c, { token }) =>
318
+ c.html(confirmationPage(token), 200, CONFIRMATION_PAGE_HEADERS),
319
+ });
243
320
 
321
+ const writeRoute = signatureRoute<VerifiedUnsubscribe>({
322
+ method: "POST",
323
+ path: DELIVERY_UNSUBSCRIBE_PATH,
324
+ entry: "signature",
325
+ verify: async (request) => verifyUnsubscribeToken(tokenFromPostRequest(request), encodedSecret),
326
+ handler: async (c, verified, deps) => {
244
327
  // Token-verify passed — everything below is a legitimate write. Don't
245
328
  // swallow write-errors as "invalid token", that would mask real bugs
246
329
  // (e.g. events-table missing, DB down) behind a misleading 400.
247
- const dispatched = await deps.dispatchSystemWrite({
248
- handlerQn:
249
- verified.kind === "address"
250
- ? DeliveryHandlers.unsubscribeAddress
251
- : DeliveryHandlers.unsubscribeUser,
252
- tenantId: verified.tenantId,
253
- payload,
254
- });
255
- if (!dispatched.isSuccess) {
256
- return c.text("Unsubscribe failed", 500);
330
+ const succeeded = await dispatchUnsubscribeWrite(verified, deps);
331
+ if (!succeeded) {
332
+ return c.html("Unsubscribe failed", 500, { "Cache-Control": "no-store" });
257
333
  }
258
- return c.text("You have been unsubscribed.", 200);
334
+ return c.html("You have been unsubscribed.", 200, { "Cache-Control": "no-store" });
259
335
  },
260
336
  });
337
+
338
+ return [confirmRoute, writeRoute];
261
339
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createSecretsFeature } from "../../secrets";
3
4
  import { createStepDispatcherFeature } from "../feature";
4
5
 
5
6
  describe("step-dispatcher (fw#2068)", () => {
@@ -7,7 +8,13 @@ describe("step-dispatcher (fw#2068)", () => {
7
8
  expect(createStepDispatcherFeature().systemScope).toBeFalsy();
8
9
  });
9
10
 
10
- test("boot-validates standalone", () => {
11
- expect(() => validateBoot([createStepDispatcherFeature()])).not.toThrow();
11
+ test("boot-validates alongside the secrets feature it requires", () => {
12
+ expect(() =>
13
+ validateBoot([createStepDispatcherFeature(), createSecretsFeature()]),
14
+ ).not.toThrow();
15
+ });
16
+
17
+ test("boot-validation rejects step-dispatcher without secrets mounted", () => {
18
+ expect(() => validateBoot([createStepDispatcherFeature()])).toThrow(/secrets/);
12
19
  });
13
20
  });
@@ -5,11 +5,13 @@
5
5
 
6
6
  import { afterEach, describe, expect, mock, test } from "bun:test";
7
7
  import type { lookup } from "node:dns/promises";
8
+ import { createSecret, type SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
8
9
  import {
9
10
  performWebhookDispatch,
10
11
  setWebhookFetch,
11
12
  setWebhookHostLookup,
12
13
  WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
14
+ type WebhookDispatchDeps,
13
15
  } from "../webhook-runner";
14
16
 
15
17
  function fakeLookupFor(addressesByHost: Readonly<Record<string, string>>): typeof lookup {
@@ -22,6 +24,26 @@ function fakeLookupFor(addressesByHost: Readonly<Record<string, string>>): typeo
22
24
 
23
25
  const fetchMock = mock<typeof fetch>();
24
26
 
27
+ const TEST_TENANT_ID = "11111111-1111-4111-8111-111111111111";
28
+ const TEST_USER_ID = "22222222-2222-4222-8222-222222222222";
29
+
30
+ const noSecretsDeps: WebhookDispatchDeps = {
31
+ tenantId: TEST_TENANT_ID,
32
+ userId: TEST_USER_ID,
33
+ secrets: undefined,
34
+ };
35
+
36
+ function fakeSecrets(value: string | undefined) {
37
+ const get = mock(async () => (value === undefined ? undefined : createSecret(value)));
38
+ const secrets: SecretsContext = {
39
+ get,
40
+ has: async () => value !== undefined,
41
+ set: async () => {},
42
+ delete: async () => true,
43
+ };
44
+ return { secrets, get };
45
+ }
46
+
25
47
  afterEach(() => {
26
48
  fetchMock.mockReset();
27
49
  setWebhookFetch(fetch);
@@ -32,11 +54,14 @@ afterEach(() => {
32
54
  describe("performWebhookDispatch — host-egress guard", () => {
33
55
  test("a private IP-literal host is rejected before any connect attempt", async () => {
34
56
  setWebhookFetch(fetchMock as unknown as typeof fetch);
35
- const result = await performWebhookDispatch({
36
- url: "http://10.0.0.5/hook",
37
- method: "POST",
38
- headers: {},
39
- });
57
+ const result = await performWebhookDispatch(
58
+ {
59
+ url: "http://10.0.0.5/hook",
60
+ method: "POST",
61
+ headers: {},
62
+ },
63
+ noSecretsDeps,
64
+ );
40
65
  expect(result.ok).toBe(false);
41
66
  expect(fetchMock).not.toHaveBeenCalled();
42
67
  });
@@ -44,11 +69,14 @@ describe("performWebhookDispatch — host-egress guard", () => {
44
69
  test("a hostname resolving to a private address is rejected, without leaking the resolved address", async () => {
45
70
  setWebhookHostLookup(fakeLookupFor({ "internal.example": "127.0.0.1" }));
46
71
  setWebhookFetch(fetchMock as unknown as typeof fetch);
47
- const result = await performWebhookDispatch({
48
- url: "http://internal.example/hook",
49
- method: "POST",
50
- headers: {},
51
- });
72
+ const result = await performWebhookDispatch(
73
+ {
74
+ url: "http://internal.example/hook",
75
+ method: "POST",
76
+ headers: {},
77
+ },
78
+ noSecretsDeps,
79
+ );
52
80
  expect(result.ok).toBe(false);
53
81
  if (!result.ok) {
54
82
  expect(result.error).not.toContain("127.0.0.1");
@@ -59,11 +87,14 @@ describe("performWebhookDispatch — host-egress guard", () => {
59
87
  test("a DNS resolution failure surfaces as a delivery error, not a thrown exception", async () => {
60
88
  setWebhookHostLookup(fakeLookupFor({}));
61
89
  setWebhookFetch(fetchMock as unknown as typeof fetch);
62
- const result = await performWebhookDispatch({
63
- url: "http://nowhere.example/hook",
64
- method: "POST",
65
- headers: {},
66
- });
90
+ const result = await performWebhookDispatch(
91
+ {
92
+ url: "http://nowhere.example/hook",
93
+ method: "POST",
94
+ headers: {},
95
+ },
96
+ noSecretsDeps,
97
+ );
67
98
  expect(result.ok).toBe(false);
68
99
  expect(fetchMock).not.toHaveBeenCalled();
69
100
  });
@@ -71,17 +102,23 @@ describe("performWebhookDispatch — host-egress guard", () => {
71
102
  test("a blocked host and a DNS resolution failure produce the exact same tenant-visible error, without the host", async () => {
72
103
  setWebhookFetch(fetchMock as unknown as typeof fetch);
73
104
 
74
- const blocked = await performWebhookDispatch({
75
- url: "http://10.0.0.5/hook",
76
- method: "POST",
77
- headers: {},
78
- });
105
+ const blocked = await performWebhookDispatch(
106
+ {
107
+ url: "http://10.0.0.5/hook",
108
+ method: "POST",
109
+ headers: {},
110
+ },
111
+ noSecretsDeps,
112
+ );
79
113
  setWebhookHostLookup(fakeLookupFor({}));
80
- const unresolvable = await performWebhookDispatch({
81
- url: "http://nowhere.example/hook",
82
- method: "POST",
83
- headers: {},
84
- });
114
+ const unresolvable = await performWebhookDispatch(
115
+ {
116
+ url: "http://nowhere.example/hook",
117
+ method: "POST",
118
+ headers: {},
119
+ },
120
+ noSecretsDeps,
121
+ );
85
122
 
86
123
  expect(blocked.ok).toBe(false);
87
124
  expect(unresolvable.ok).toBe(false);
@@ -96,11 +133,14 @@ describe("performWebhookDispatch — host-egress guard", () => {
96
133
  setWebhookFetch(fetchMock as unknown as typeof fetch);
97
134
  fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
98
135
 
99
- const result = await performWebhookDispatch({
100
- url: "http://webhook-receiver.internal/hook",
101
- method: "POST",
102
- headers: {},
103
- });
136
+ const result = await performWebhookDispatch(
137
+ {
138
+ url: "http://webhook-receiver.internal/hook",
139
+ method: "POST",
140
+ headers: {},
141
+ },
142
+ noSecretsDeps,
143
+ );
104
144
 
105
145
  expect(result.ok).toBe(true);
106
146
  expect(fetchMock).toHaveBeenCalledTimes(1);
@@ -113,11 +153,14 @@ describe("performWebhookDispatch — host-egress guard", () => {
113
153
  setWebhookFetch(fetchMock as unknown as typeof fetch);
114
154
  fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
115
155
 
116
- const result = await performWebhookDispatch({
117
- url: "https://hooks.example.com/incident",
118
- method: "POST",
119
- headers: {},
120
- });
156
+ const result = await performWebhookDispatch(
157
+ {
158
+ url: "https://hooks.example.com/incident",
159
+ method: "POST",
160
+ headers: {},
161
+ },
162
+ noSecretsDeps,
163
+ );
121
164
 
122
165
  expect(result.ok).toBe(true);
123
166
  const [calledUrl, init] = fetchMock.mock.calls[0]!;
@@ -129,3 +172,112 @@ describe("performWebhookDispatch — host-egress guard", () => {
129
172
  );
130
173
  });
131
174
  });
175
+
176
+ describe("performWebhookDispatch — auth.secret resolution", () => {
177
+ test("bearer auth sends the tenant's secret under the Authorization header", async () => {
178
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
179
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
180
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "hooks.example";
181
+ const { secrets, get } = fakeSecrets("tok_live_abc123");
182
+
183
+ const result = await performWebhookDispatch(
184
+ {
185
+ url: "http://hooks.example/hook",
186
+ method: "POST",
187
+ headers: {},
188
+ auth: { kind: "bearer", secret: "incident-hook" },
189
+ },
190
+ { tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets },
191
+ );
192
+
193
+ expect(result.ok).toBe(true);
194
+ expect(get).toHaveBeenCalledWith(TEST_TENANT_ID, "step-dispatcher:webhook-auth.incident-hook", {
195
+ userId: TEST_USER_ID,
196
+ handlerName: "step-dispatcher:webhook.send",
197
+ });
198
+ const [, init] = fetchMock.mock.calls[0]!;
199
+ expect(new Headers(init?.headers).get("authorization")).toBe("Bearer tok_live_abc123");
200
+ });
201
+
202
+ test("header auth sends the tenant's secret under the configured header name", async () => {
203
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
204
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
205
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "hooks.example";
206
+ const { secrets } = fakeSecrets("shared-secret-xyz");
207
+
208
+ const result = await performWebhookDispatch(
209
+ {
210
+ url: "http://hooks.example/hook",
211
+ method: "POST",
212
+ headers: {},
213
+ auth: { kind: "header", name: "x-hub-signature", secret: "incident-hook" },
214
+ },
215
+ { tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets },
216
+ );
217
+
218
+ expect(result.ok).toBe(true);
219
+ const [, init] = fetchMock.mock.calls[0]!;
220
+ expect(new Headers(init?.headers).get("x-hub-signature")).toBe("shared-secret-xyz");
221
+ });
222
+
223
+ test("a missing secret fails without leaking the secret name or fetching", async () => {
224
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
225
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "hooks.example";
226
+ const { secrets } = fakeSecrets(undefined);
227
+
228
+ const result = await performWebhookDispatch(
229
+ {
230
+ url: "http://hooks.example/hook",
231
+ method: "POST",
232
+ headers: {},
233
+ auth: { kind: "bearer", secret: "incident-hook" },
234
+ },
235
+ { tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets },
236
+ );
237
+
238
+ expect(result.ok).toBe(false);
239
+ if (result.ok) throw new Error("unreachable");
240
+ expect(result.error).toBe("webhook auth secret is not available");
241
+ expect(result.error).not.toContain("incident-hook");
242
+ expect(fetchMock).not.toHaveBeenCalled();
243
+ });
244
+
245
+ test("an undefined secrets context fails the same generic way as a missing secret", async () => {
246
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
247
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "hooks.example";
248
+
249
+ const result = await performWebhookDispatch(
250
+ {
251
+ url: "http://hooks.example/hook",
252
+ method: "POST",
253
+ headers: {},
254
+ auth: { kind: "bearer", secret: "incident-hook" },
255
+ },
256
+ noSecretsDeps,
257
+ );
258
+
259
+ expect(result.ok).toBe(false);
260
+ if (result.ok) throw new Error("unreachable");
261
+ expect(result.error).toBe("webhook auth secret is not available");
262
+ expect(fetchMock).not.toHaveBeenCalled();
263
+ });
264
+
265
+ test("an invalid url is rejected before any secret is read", async () => {
266
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
267
+ const { secrets, get } = fakeSecrets("should-never-be-read");
268
+
269
+ const result = await performWebhookDispatch(
270
+ {
271
+ url: "not-a-url",
272
+ method: "POST",
273
+ headers: {},
274
+ auth: { kind: "bearer", secret: "incident-hook" },
275
+ },
276
+ { tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets },
277
+ );
278
+
279
+ expect(result.ok).toBe(false);
280
+ expect(get).not.toHaveBeenCalled();
281
+ expect(fetchMock).not.toHaveBeenCalled();
282
+ });
283
+ });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.321.0",
4
+ "type": "breaking",
5
+ "title": "Webhook auth secrets are now tenant-owned via the secrets feature, not a global env var",
6
+ "detail": "`r.step.webhook.send`'s `auth` config renamed `secretRef` → `secret`. The\nvalue is now a name inside the tenant-owned secrets namespace\n`step-dispatcher:webhook-auth.<secret>` (secrets feature), resolved via\n`SecretsContext.get()` at dispatch time with an audit read stamped with\nthe triggering event's userId (or the system actor for cron/resume\ndispatches). `setWebhookSecretResolver` / the `WEBHOOK_SECRET_*` env\nconvention are gone. `performWebhookDispatch(spec)` now takes a required\nsecond `deps: { tenantId, userId, secrets }` argument.",
7
+ "migration": "Mount `createSecretsFeature()` (with a `MasterKeyProvider`) alongside\n`createStepDispatcherFeature()` — boot-validation now fails without it.\nFor each tenant that uses `r.step.webhook.send` with `auth`, set the\ncredential via `secrets:write:set` under\n`step-dispatcher:webhook-auth.<secret>` (the same name passed as\n`auth.secret`). Rename `auth.secretRef` → `auth.secret` at every\n`r.step.webhook.send({ auth: {...} })` call site. Remove any\n`WEBHOOK_SECRET_*` env vars — they're no longer read.\n\nA `step.dispatch-requested` event already enqueued before this upgrade\n(still carrying the old `secretRef` shape) fails validation on drain and\nis recorded as `step.dispatch-failed` with `error: \"invalid dispatch\npayload\"` instead of silently resolving a stale ref — drain the queue (or\naccept the one-time failed event) before deploying.\n\nTests calling `performWebhookDispatch` directly or using\n`setWebhookSecretResolver` must pass a `SecretsContext` (or `undefined`)\nvia the new `deps` argument instead."
8
+ },
2
9
  {
3
10
  "version": "0.320.0",
4
11
  "type": "fix",