@classytic/arc 2.22.0 → 2.23.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/dist/{BaseController-C6FlRWxF.mjs → BaseController-BQ8KNvWC.mjs} +3 -2
- package/dist/{ResourceRegistry-7RnrEWNP.mjs → ResourceRegistry-y9Hnypus.mjs} +1 -0
- package/dist/{actionPermissions-ze70xXJi.mjs → actionPermissions-lszoJle1.mjs} +1 -1
- package/dist/applyPermissionResult-Tw4iXtlf.mjs +264 -0
- package/dist/audit/index.d.mts +1 -1
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.mjs +2 -2
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/describe.mjs +1 -1
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/init.mjs +1 -1
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/cli/index.mjs +1 -1
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +3 -3
- package/dist/{core-b6fgFgnd.mjs → core-DQ6rDTyH.mjs} +10 -9
- package/dist/{createActionRouter-h1cEEaEX.mjs → createActionRouter-BuOv-m4Y.mjs} +3 -3
- package/dist/{createAggregationRouter-B1C0kvTL.mjs → createAggregationRouter-CgJv_BU_.mjs} +1 -1
- package/dist/{createApp-Ci7yG5gv.mjs → createApp-CjOeWJ9_.mjs} +11 -2
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +1 -1
- package/dist/{errorHandler-BTzXSwIg.mjs → errorHandler-Bq2OeEQL.mjs} +10 -7
- package/dist/events/index.mjs +5 -2
- package/dist/factory/index.d.mts +20 -4
- package/dist/factory/index.mjs +31 -11
- package/dist/{fastify-augmentation-9_fuetB4.d.mts → fastify-augmentation-g_f9omSc.d.mts} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +7 -0
- package/dist/idempotency/index.mjs +5 -17
- package/dist/{index-DbtgCOCj.d.mts → index-BBsvQt7U.d.mts} +26 -4
- package/dist/{index-BEu4wjbL.d.mts → index-Ba10fdKp.d.mts} +11 -2
- package/dist/{index-D8MKYGDa.d.mts → index-CC5ruDGh.d.mts} +2 -2
- package/dist/{index-ogIHvOjo.d.mts → index-wMoWBLkn.d.mts} +67 -1
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +6 -5
- package/dist/{init-DleK3p9f.mjs → init-CjO4p_q1.mjs} +5 -2
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +66 -3
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/webhooks.d.mts +123 -3
- package/dist/integrations/webhooks.mjs +160 -5
- package/dist/interface-n5hpKg1d.mjs +16 -0
- package/dist/{loadResourcesFromEntry-OMCvIEE7.mjs → loadResourcesFromEntry-kvGT6EcO.mjs} +1 -1
- package/dist/{mcpPlugin-ByA2tf6D.mjs → mcpPlugin-BtzIL_kM.mjs} +308 -39
- package/dist/middleware/index.d.mts +1 -1
- package/dist/{openapi-BRpMZD7C.mjs → openapi-jSDx-VDv.mjs} +3 -3
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +3 -2
- package/dist/{permissions-BJMuKMl7.mjs → permissions-tbxFHxjZ.mjs} +77 -264
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/plugins/index.d.mts +6 -6
- package/dist/plugins/index.mjs +5 -4
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +1 -1
- package/dist/presets/filesUpload.mjs +1 -1
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/search.d.mts +1 -1
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-gw0ciIUD.mjs → presets-BOe4Ugkl.mjs} +1 -1
- package/dist/realtime-I8OrfnMR.mjs +375 -0
- package/dist/{registerRoute-xHc42fwB.mjs → registerRoute-CWO6mdYe.mjs} +1 -1
- package/dist/registry/index.d.mts +2 -2
- package/dist/registry/index.mjs +1 -1
- package/dist/{schemaConverter-Ds5reSGs.mjs → schemaConverter-DJvodiHj.mjs} +4 -1
- package/dist/{schemaIR-B-6SPe7g.mjs → schemaIR-Ca7ySNPx.mjs} +1 -1
- package/dist/scope/index.mjs +1 -1
- package/dist/scope-LONQ3mfZ.mjs +37 -0
- package/dist/simpleEqualityMatcher-Cg05HPsn.mjs +68 -0
- package/dist/{sse-CfUjwjDk.mjs → sse-0u4P4-OI.mjs} +7 -30
- package/dist/sseStream-BQ7Qaynp.mjs +57 -0
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/index.mjs +2 -2
- package/dist/types/index.d.mts +1 -1
- package/dist/{types-Bc42CUyk.d.mts → types-5OuqeTDX.d.mts} +37 -2
- package/dist/utils/index.d.mts +62 -2
- package/dist/utils/index.mjs +5 -3
- package/dist/{utils-FhJ88TJE.mjs → utils-CZyeRXTh.mjs} +21 -69
- package/dist/{versioning-DLwklPlG.d.mts → versioning-D6PvCwpq.d.mts} +43 -4
- package/package.json +64 -4
- package/skills/arc/references/production.md +734 -696
- package/dist/scope-k15RXfcq.mjs +0 -64
|
@@ -36,13 +36,51 @@ interface WebhookPluginOptions {
|
|
|
36
36
|
timeout?: number;
|
|
37
37
|
/** Max delivery log entries kept in memory (default: 1000) */
|
|
38
38
|
maxLogEntries?: number;
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Max concurrent deliveries per event (default: 5). Set to 1 for
|
|
41
|
+
* sequential. Must be >= 1 — the plugin throws at registration otherwise
|
|
42
|
+
* (a zero would make the delivery loop spin forever without progress).
|
|
43
|
+
*/
|
|
40
44
|
concurrency?: number;
|
|
45
|
+
/**
|
|
46
|
+
* URL policy hook — the SSRF seam for tenant-registered URLs. Throw to
|
|
47
|
+
* reject. Pass {@link defaultWebhookUrlPolicy} to require HTTPS and block
|
|
48
|
+
* localhost / link-local / private-network / cloud-metadata destinations,
|
|
49
|
+
* or supply your own (allowlists, per-tenant rules).
|
|
50
|
+
*
|
|
51
|
+
* Enforcement points when set:
|
|
52
|
+
* - `register()` — new subscriptions are validated BEFORE saving.
|
|
53
|
+
* - Plugin init — every PERSISTED subscription is re-validated; failures
|
|
54
|
+
* are excluded from dispatch and logged (they stay in the store for
|
|
55
|
+
* the host to fix), so pre-policy rows and out-of-band store writes
|
|
56
|
+
* can't bypass the policy.
|
|
57
|
+
* - Delivery — `fetch` runs with `redirect: "manual"`, so an allowed
|
|
58
|
+
* public URL can't 302 into a blocked private/metadata target; a 3xx
|
|
59
|
+
* records as a failed delivery.
|
|
60
|
+
*
|
|
61
|
+
* Arc deliberately ships no DEFAULT policy — deployments differ (internal
|
|
62
|
+
* webhooks to private services are legitimate), and DNS-rebinding
|
|
63
|
+
* protection belongs at the actual outbound network boundary, not in the
|
|
64
|
+
* framework.
|
|
65
|
+
*/
|
|
66
|
+
validateUrl?: (url: URL, subscription: WebhookSubscription) => void | Promise<void>;
|
|
41
67
|
}
|
|
68
|
+
/** `WebhookSubscription` as returned by `list()` — secret withheld. */
|
|
69
|
+
type WebhookSubscriptionPublic = Omit<WebhookSubscription, "secret">;
|
|
42
70
|
interface WebhookManager {
|
|
43
71
|
register(sub: WebhookSubscription): Promise<void> | void;
|
|
44
72
|
unregister(id: string): Promise<void> | void;
|
|
45
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Enumerate subscriptions WITHOUT their secrets — safe to surface in
|
|
75
|
+
* admin UIs / API responses. Use {@link WebhookManager.getSecret} for
|
|
76
|
+
* the privileged retrieval path.
|
|
77
|
+
*/
|
|
78
|
+
list(): WebhookSubscriptionPublic[];
|
|
79
|
+
/**
|
|
80
|
+
* Privileged secret retrieval — separate from `list()` so enumerating
|
|
81
|
+
* subscriptions never leaks signing material into logs or UIs.
|
|
82
|
+
*/
|
|
83
|
+
getSecret(id: string): string | undefined;
|
|
46
84
|
deliveryLog(limit?: number): WebhookDeliveryRecord[];
|
|
47
85
|
}
|
|
48
86
|
declare module "fastify" {
|
|
@@ -53,9 +91,91 @@ declare module "fastify" {
|
|
|
53
91
|
/**
|
|
54
92
|
* Sign a payload with HMAC-SHA256 for outbound webhook delivery.
|
|
55
93
|
*
|
|
94
|
+
* @deprecated LEGACY (pre-2.23) format — signs the BODY ONLY, so a captured
|
|
95
|
+
* request can be replayed indefinitely and the event-id header is not
|
|
96
|
+
* cryptographically bound to the body. Still emitted on the legacy
|
|
97
|
+
* `x-webhook-signature` header for existing receivers; new receivers should
|
|
98
|
+
* verify the v1 contract (`x-arc-webhook-signature`) via {@link verifyWebhook}.
|
|
99
|
+
* Legacy emission is scheduled for removal in v3.
|
|
100
|
+
*
|
|
56
101
|
* @returns `sha256=<hex>` — the format written to `x-webhook-signature`
|
|
57
102
|
*/
|
|
58
103
|
declare function signPayload(payload: string, secret: string): string;
|
|
104
|
+
/**
|
|
105
|
+
* Signed-string layout for the v1 webhook contract:
|
|
106
|
+
*
|
|
107
|
+
* v1.<timestampMs>.<deliveryId>.<body>
|
|
108
|
+
*
|
|
109
|
+
* Binding the timestamp bounds replay to the verifier's tolerance window,
|
|
110
|
+
* and binding the delivery id lets receivers deduplicate redeliveries on an
|
|
111
|
+
* AUTHENTICATED value (the legacy `x-webhook-id` header was unsigned — an
|
|
112
|
+
* attacker replaying a captured request could change it and defeat dedup).
|
|
113
|
+
*
|
|
114
|
+
* Wire headers:
|
|
115
|
+
* - `x-arc-webhook-signature: v1=<hex hmac-sha256>`
|
|
116
|
+
* - `x-arc-webhook-timestamp: <unix epoch ms>`
|
|
117
|
+
* - `x-arc-webhook-delivery: <delivery id>` (arc sends the event's
|
|
118
|
+
* `meta.id` — STABLE across retries of the same event, so dedup on it
|
|
119
|
+
* collapses redeliveries)
|
|
120
|
+
*/
|
|
121
|
+
declare function signWebhook(body: string, secret: string, meta: {
|
|
122
|
+
timestamp: number;
|
|
123
|
+
deliveryId: string;
|
|
124
|
+
}): string;
|
|
125
|
+
interface VerifyWebhookInput {
|
|
126
|
+
/** Raw request body — the exact bytes the sender signed (`req.rawBody`). */
|
|
127
|
+
body: string | Buffer;
|
|
128
|
+
/** Shared secret. */
|
|
129
|
+
secret: string;
|
|
130
|
+
/** `x-arc-webhook-signature` header value (`v1=<hex>`). */
|
|
131
|
+
signature: string | undefined;
|
|
132
|
+
/** `x-arc-webhook-timestamp` header value (unix epoch ms). */
|
|
133
|
+
timestamp: string | number | undefined;
|
|
134
|
+
/** `x-arc-webhook-delivery` header value. */
|
|
135
|
+
deliveryId: string | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* Max allowed clock skew between sender timestamp and now, in ms.
|
|
138
|
+
* @default 300_000 (5 minutes)
|
|
139
|
+
*/
|
|
140
|
+
toleranceMs?: number;
|
|
141
|
+
/** Injectable clock for tests. @default Date.now */
|
|
142
|
+
now?: () => number;
|
|
143
|
+
}
|
|
144
|
+
type VerifyWebhookResult = {
|
|
145
|
+
valid: true;
|
|
146
|
+
/** The AUTHENTICATED delivery id — dedup redeliveries on this. */
|
|
147
|
+
deliveryId: string;
|
|
148
|
+
/** Sender timestamp (unix epoch ms), authenticated. */
|
|
149
|
+
timestamp: number;
|
|
150
|
+
} | {
|
|
151
|
+
valid: false;
|
|
152
|
+
reason: "missing_headers" | "bad_timestamp" | "expired" | "bad_signature";
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Verify an inbound v1 arc webhook: signature + timestamp tolerance, and
|
|
156
|
+
* return the authenticated delivery id for receiver-side deduplication.
|
|
157
|
+
*
|
|
158
|
+
* ⚠ Pass `req.rawBody` (route opted in via `config: { rawBody: true }`),
|
|
159
|
+
* never the parsed `req.body` — re-serialised JSON won't match the HMAC.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```typescript
|
|
163
|
+
* fastify.post('/hooks/arc', { config: { rawBody: true } }, async (req, reply) => {
|
|
164
|
+
* const result = verifyWebhook({
|
|
165
|
+
* body: req.rawBody,
|
|
166
|
+
* secret: process.env.WEBHOOK_SECRET,
|
|
167
|
+
* signature: req.headers['x-arc-webhook-signature'] as string,
|
|
168
|
+
* timestamp: req.headers['x-arc-webhook-timestamp'] as string,
|
|
169
|
+
* deliveryId: req.headers['x-arc-webhook-delivery'] as string,
|
|
170
|
+
* });
|
|
171
|
+
* if (!result.valid) return reply.status(401).send({ error: result.reason });
|
|
172
|
+
* if (await seen(result.deliveryId)) return reply.send({ ok: true }); // dedup
|
|
173
|
+
* // ... handle, then mark result.deliveryId seen
|
|
174
|
+
* });
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
declare function verifyWebhook(input: VerifyWebhookInput): VerifyWebhookResult;
|
|
178
|
+
declare function defaultWebhookUrlPolicy(url: URL): void;
|
|
59
179
|
/** Options for `verifySignature` — customize for non-Arc webhook senders */
|
|
60
180
|
interface VerifySignatureOptions {
|
|
61
181
|
/**
|
|
@@ -118,4 +238,4 @@ declare function verifySignature(body: string | Buffer, secret: string, signatur
|
|
|
118
238
|
declare const webhookPlugin: FastifyPluginAsync<WebhookPluginOptions>;
|
|
119
239
|
declare const _default: FastifyPluginAsync<WebhookPluginOptions>;
|
|
120
240
|
//#endregion
|
|
121
|
-
export { VerifySignatureOptions, WebhookDeliveryRecord, WebhookManager, WebhookPluginOptions, WebhookStore, WebhookSubscription, _default as default, signPayload, verifySignature, webhookPlugin };
|
|
241
|
+
export { VerifySignatureOptions, VerifyWebhookInput, VerifyWebhookResult, WebhookDeliveryRecord, WebhookManager, WebhookPluginOptions, WebhookStore, WebhookSubscription, WebhookSubscriptionPublic, _default as default, defaultWebhookUrlPolicy, signPayload, signWebhook, verifySignature, verifyWebhook, webhookPlugin };
|
|
@@ -33,6 +33,13 @@ import fp from "fastify-plugin";
|
|
|
33
33
|
/**
|
|
34
34
|
* Sign a payload with HMAC-SHA256 for outbound webhook delivery.
|
|
35
35
|
*
|
|
36
|
+
* @deprecated LEGACY (pre-2.23) format — signs the BODY ONLY, so a captured
|
|
37
|
+
* request can be replayed indefinitely and the event-id header is not
|
|
38
|
+
* cryptographically bound to the body. Still emitted on the legacy
|
|
39
|
+
* `x-webhook-signature` header for existing receivers; new receivers should
|
|
40
|
+
* verify the v1 contract (`x-arc-webhook-signature`) via {@link verifyWebhook}.
|
|
41
|
+
* Legacy emission is scheduled for removal in v3.
|
|
42
|
+
*
|
|
36
43
|
* @returns `sha256=<hex>` — the format written to `x-webhook-signature`
|
|
37
44
|
*/
|
|
38
45
|
function signPayload(payload, secret) {
|
|
@@ -41,6 +48,120 @@ function signPayload(payload, secret) {
|
|
|
41
48
|
return `sha256=${hmac.digest("hex")}`;
|
|
42
49
|
}
|
|
43
50
|
/**
|
|
51
|
+
* Signed-string layout for the v1 webhook contract:
|
|
52
|
+
*
|
|
53
|
+
* v1.<timestampMs>.<deliveryId>.<body>
|
|
54
|
+
*
|
|
55
|
+
* Binding the timestamp bounds replay to the verifier's tolerance window,
|
|
56
|
+
* and binding the delivery id lets receivers deduplicate redeliveries on an
|
|
57
|
+
* AUTHENTICATED value (the legacy `x-webhook-id` header was unsigned — an
|
|
58
|
+
* attacker replaying a captured request could change it and defeat dedup).
|
|
59
|
+
*
|
|
60
|
+
* Wire headers:
|
|
61
|
+
* - `x-arc-webhook-signature: v1=<hex hmac-sha256>`
|
|
62
|
+
* - `x-arc-webhook-timestamp: <unix epoch ms>`
|
|
63
|
+
* - `x-arc-webhook-delivery: <delivery id>` (arc sends the event's
|
|
64
|
+
* `meta.id` — STABLE across retries of the same event, so dedup on it
|
|
65
|
+
* collapses redeliveries)
|
|
66
|
+
*/
|
|
67
|
+
function signWebhook(body, secret, meta) {
|
|
68
|
+
const hmac = createHmac("sha256", secret);
|
|
69
|
+
hmac.update(`v1.${meta.timestamp}.${meta.deliveryId}.${body}`);
|
|
70
|
+
return `v1=${hmac.digest("hex")}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Verify an inbound v1 arc webhook: signature + timestamp tolerance, and
|
|
74
|
+
* return the authenticated delivery id for receiver-side deduplication.
|
|
75
|
+
*
|
|
76
|
+
* ⚠ Pass `req.rawBody` (route opted in via `config: { rawBody: true }`),
|
|
77
|
+
* never the parsed `req.body` — re-serialised JSON won't match the HMAC.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```typescript
|
|
81
|
+
* fastify.post('/hooks/arc', { config: { rawBody: true } }, async (req, reply) => {
|
|
82
|
+
* const result = verifyWebhook({
|
|
83
|
+
* body: req.rawBody,
|
|
84
|
+
* secret: process.env.WEBHOOK_SECRET,
|
|
85
|
+
* signature: req.headers['x-arc-webhook-signature'] as string,
|
|
86
|
+
* timestamp: req.headers['x-arc-webhook-timestamp'] as string,
|
|
87
|
+
* deliveryId: req.headers['x-arc-webhook-delivery'] as string,
|
|
88
|
+
* });
|
|
89
|
+
* if (!result.valid) return reply.status(401).send({ error: result.reason });
|
|
90
|
+
* if (await seen(result.deliveryId)) return reply.send({ ok: true }); // dedup
|
|
91
|
+
* // ... handle, then mark result.deliveryId seen
|
|
92
|
+
* });
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
function verifyWebhook(input) {
|
|
96
|
+
const { body, secret, signature, deliveryId, toleranceMs = 3e5, now = Date.now } = input;
|
|
97
|
+
if (typeof body !== "string" && !Buffer.isBuffer(body)) throw new TypeError("verifyWebhook: body must be a string or Buffer (the exact bytes the sender signed). Add `config: { rawBody: true }` to the route and pass req.rawBody — not req.body.");
|
|
98
|
+
if (!signature || !deliveryId || input.timestamp === void 0 || input.timestamp === "") return {
|
|
99
|
+
valid: false,
|
|
100
|
+
reason: "missing_headers"
|
|
101
|
+
};
|
|
102
|
+
const timestamp = Number(input.timestamp);
|
|
103
|
+
if (!Number.isFinite(timestamp)) return {
|
|
104
|
+
valid: false,
|
|
105
|
+
reason: "bad_timestamp"
|
|
106
|
+
};
|
|
107
|
+
if (Math.abs(now() - timestamp) > toleranceMs) return {
|
|
108
|
+
valid: false,
|
|
109
|
+
reason: "expired"
|
|
110
|
+
};
|
|
111
|
+
const expected = signWebhook(typeof body === "string" ? body : body.toString("utf8"), secret, {
|
|
112
|
+
timestamp,
|
|
113
|
+
deliveryId
|
|
114
|
+
});
|
|
115
|
+
const a = Buffer.from(signature);
|
|
116
|
+
const b = Buffer.from(expected);
|
|
117
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return {
|
|
118
|
+
valid: false,
|
|
119
|
+
reason: "bad_signature"
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
valid: true,
|
|
123
|
+
deliveryId,
|
|
124
|
+
timestamp
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Opt-in URL policy for tenant-registered webhooks: requires HTTPS and
|
|
129
|
+
* rejects loopback, link-local, private-network, and cloud-metadata
|
|
130
|
+
* destinations. Pass as `validateUrl: defaultWebhookUrlPolicy`.
|
|
131
|
+
*
|
|
132
|
+
* Scope note: this validates the URL LITERAL at registration time. It does
|
|
133
|
+
* not resolve DNS — rebinding/level-2 SSRF protection belongs at the actual
|
|
134
|
+
* outbound network boundary (egress proxy, network policy), where the real
|
|
135
|
+
* connection target is known. Internal deployments that legitimately POST
|
|
136
|
+
* to private services should write their own narrower policy instead.
|
|
137
|
+
*/
|
|
138
|
+
const BLOCKED_V4 = /^(?:127\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|169\.254\.|0\.)/;
|
|
139
|
+
function defaultWebhookUrlPolicy(url) {
|
|
140
|
+
if (url.protocol !== "https:") throw new Error(`Webhook URL must use https (got ${url.protocol}//)`);
|
|
141
|
+
let host = url.hostname.toLowerCase();
|
|
142
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
143
|
+
const blockedName = host === "localhost" || host.endsWith(".localhost") || host === "metadata.google.internal";
|
|
144
|
+
const isV6 = host.includes(":");
|
|
145
|
+
const mappedV4 = isV6 ? decodeMappedV4(host) : void 0;
|
|
146
|
+
const blockedV6 = isV6 && (host === "::1" || host === "::" || /^f[cd]/.test(host) || /^fe[89ab]/.test(host) || mappedV4 !== void 0 && BLOCKED_V4.test(mappedV4));
|
|
147
|
+
const blockedV4 = !isV6 && BLOCKED_V4.test(host);
|
|
148
|
+
if (blockedName || blockedV4 || blockedV6) throw new Error(`Webhook URL host '${host}' is not allowed (loopback/private/metadata)`);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Decode an IPv4-mapped IPv6 literal to its dotted-quad form. Accepts both
|
|
152
|
+
* the textual spelling (`::ffff:1.2.3.4`) and the hex-group canonical form
|
|
153
|
+
* WHATWG URL produces (`::ffff:102:304`). Returns undefined for anything else.
|
|
154
|
+
*/
|
|
155
|
+
function decodeMappedV4(host) {
|
|
156
|
+
const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(host)?.[1];
|
|
157
|
+
if (dotted) return dotted;
|
|
158
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
|
|
159
|
+
if (!hex?.[1] || !hex[2]) return void 0;
|
|
160
|
+
const hi = Number.parseInt(hex[1], 16);
|
|
161
|
+
const lo = Number.parseInt(hex[2], 16);
|
|
162
|
+
return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
44
165
|
* Verify an inbound webhook signature using timing-safe comparison.
|
|
45
166
|
*
|
|
46
167
|
* Works with Arc's own `signPayload` format by default (`sha256=<hex>`),
|
|
@@ -133,9 +254,24 @@ const webhookPlugin = async (fastify, opts = {}) => {
|
|
|
133
254
|
const timeout = opts.timeout ?? 1e4;
|
|
134
255
|
const maxLogEntries = opts.maxLogEntries ?? 1e3;
|
|
135
256
|
const concurrency = opts.concurrency ?? 5;
|
|
257
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error(`webhookPlugin: concurrency must be an integer >= 1 (got ${String(opts.concurrency)})`);
|
|
136
258
|
let subscriptions = [];
|
|
137
259
|
const log = [];
|
|
138
260
|
subscriptions = await store.getAll();
|
|
261
|
+
if (opts.validateUrl) {
|
|
262
|
+
const admitted = [];
|
|
263
|
+
for (const sub of subscriptions) try {
|
|
264
|
+
await opts.validateUrl(new URL(sub.url), sub);
|
|
265
|
+
admitted.push(sub);
|
|
266
|
+
} catch (err) {
|
|
267
|
+
fastify.log.warn({
|
|
268
|
+
subscriptionId: sub.id,
|
|
269
|
+
url: sub.url,
|
|
270
|
+
err
|
|
271
|
+
}, "webhookPlugin: persisted subscription failed the URL policy — excluded from dispatch. Fix the URL or unregister the subscription.");
|
|
272
|
+
}
|
|
273
|
+
subscriptions = admitted;
|
|
274
|
+
}
|
|
139
275
|
async function dispatchEvent(event) {
|
|
140
276
|
const matching = subscriptions.filter((s) => matchesPattern(s.events, event.type));
|
|
141
277
|
if (matching.length === 0) return;
|
|
@@ -152,7 +288,12 @@ const webhookPlugin = async (fastify, opts = {}) => {
|
|
|
152
288
|
timestamp: /* @__PURE__ */ new Date()
|
|
153
289
|
};
|
|
154
290
|
try {
|
|
155
|
-
const
|
|
291
|
+
const timestamp = Date.now();
|
|
292
|
+
const v1Signature = signWebhook(body, sub.secret, {
|
|
293
|
+
timestamp,
|
|
294
|
+
deliveryId: event.meta.id
|
|
295
|
+
});
|
|
296
|
+
const legacySignature = signPayload(body, sub.secret);
|
|
156
297
|
const ac = new AbortController();
|
|
157
298
|
const timer = setTimeout(() => ac.abort(), timeout);
|
|
158
299
|
try {
|
|
@@ -160,12 +301,16 @@ const webhookPlugin = async (fastify, opts = {}) => {
|
|
|
160
301
|
method: "POST",
|
|
161
302
|
headers: {
|
|
162
303
|
"content-type": "application/json",
|
|
163
|
-
"x-webhook-signature":
|
|
304
|
+
"x-arc-webhook-signature": v1Signature,
|
|
305
|
+
"x-arc-webhook-timestamp": String(timestamp),
|
|
306
|
+
"x-arc-webhook-delivery": event.meta.id,
|
|
307
|
+
"x-webhook-signature": legacySignature,
|
|
164
308
|
"x-webhook-id": event.meta.id,
|
|
165
309
|
"x-webhook-event": event.type
|
|
166
310
|
},
|
|
167
311
|
body,
|
|
168
|
-
signal: ac.signal
|
|
312
|
+
signal: ac.signal,
|
|
313
|
+
...opts.validateUrl ? { redirect: "manual" } : {}
|
|
169
314
|
});
|
|
170
315
|
record.success = response.ok;
|
|
171
316
|
record.status = response.status;
|
|
@@ -188,6 +333,13 @@ const webhookPlugin = async (fastify, opts = {}) => {
|
|
|
188
333
|
if (fastify.events) unsubscribe = await fastify.events.subscribe("*", dispatchEvent);
|
|
189
334
|
fastify.decorate("webhooks", {
|
|
190
335
|
async register(sub) {
|
|
336
|
+
let parsed;
|
|
337
|
+
try {
|
|
338
|
+
parsed = new URL(sub.url);
|
|
339
|
+
} catch {
|
|
340
|
+
throw new Error(`webhookPlugin: invalid webhook URL '${sub.url}'`);
|
|
341
|
+
}
|
|
342
|
+
if (opts.validateUrl) await opts.validateUrl(parsed, sub);
|
|
191
343
|
await store.save(sub);
|
|
192
344
|
subscriptions = subscriptions.filter((s) => s.id !== sub.id);
|
|
193
345
|
subscriptions.push(sub);
|
|
@@ -197,7 +349,10 @@ const webhookPlugin = async (fastify, opts = {}) => {
|
|
|
197
349
|
subscriptions = subscriptions.filter((s) => s.id !== id);
|
|
198
350
|
},
|
|
199
351
|
list() {
|
|
200
|
-
return
|
|
352
|
+
return subscriptions.map(({ secret: _secret, ...pub }) => pub);
|
|
353
|
+
},
|
|
354
|
+
getSecret(id) {
|
|
355
|
+
return subscriptions.find((s) => s.id === id)?.secret;
|
|
201
356
|
},
|
|
202
357
|
deliveryLog(limit) {
|
|
203
358
|
if (limit) return log.slice(-limit);
|
|
@@ -217,4 +372,4 @@ var webhooks_default = fp(webhookPlugin, {
|
|
|
217
372
|
dependencies: ["arc-events"]
|
|
218
373
|
});
|
|
219
374
|
//#endregion
|
|
220
|
-
export { webhooks_default as default, signPayload, verifySignature, webhookPlugin };
|
|
375
|
+
export { webhooks_default as default, defaultWebhookUrlPolicy, signPayload, signWebhook, verifySignature, verifyWebhook, webhookPlugin };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/idempotency/stores/interface.ts
|
|
2
|
+
/**
|
|
3
|
+
* Helper to create a result object
|
|
4
|
+
*/
|
|
5
|
+
function createIdempotencyResult(statusCode, body, headers, ttlMs) {
|
|
6
|
+
const now = /* @__PURE__ */ new Date();
|
|
7
|
+
return {
|
|
8
|
+
statusCode,
|
|
9
|
+
headers,
|
|
10
|
+
body,
|
|
11
|
+
createdAt: now,
|
|
12
|
+
expiresAt: new Date(now.getTime() + ttlMs)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { createIdempotencyResult as t };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as ResourceRegistry } from "./ResourceRegistry-
|
|
1
|
+
import { t as ResourceRegistry } from "./ResourceRegistry-y9Hnypus.mjs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
//#region src/cli/utils/loadResourcesFromEntry.ts
|