@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.
- package/package.json +9 -9
- 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/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-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
|
@@ -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
|
-
* [
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
311
|
+
"missing unsubscribe token",
|
|
227
312
|
);
|
|
228
313
|
}
|
|
314
|
+
await verifyUnsubscribeToken(token, encodedSecret);
|
|
315
|
+
return { token };
|
|
229
316
|
},
|
|
230
|
-
handler: async (c,
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
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.
|
|
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
|
|
11
|
-
expect(() =>
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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",
|