@classytic/arc 2.22.0 → 2.25.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--M8EMzt0.mjs} +92 -22
- package/dist/QueryCache-DOMT_iq7.d.mts +72 -0
- package/dist/{ResourceRegistry-7RnrEWNP.mjs → ResourceRegistry-y9Hnypus.mjs} +1 -0
- package/dist/actionPermissions-CAphcmUG.mjs +24 -0
- package/dist/applyPermissionResult-Tw4iXtlf.mjs +264 -0
- package/dist/audit/index.d.mts +32 -3
- package/dist/audit/index.mjs +7 -4
- package/dist/auth/index.d.mts +2 -2
- package/dist/auth/index.mjs +264 -260
- package/dist/{buildHandler-D_2ULWxZ.mjs → buildHandler-Dtwd8fdz.mjs} +1 -1
- package/dist/cache/index.d.mts +41 -3
- package/dist/cache/index.mjs +33 -4
- 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/generate.mjs +1 -1
- package/dist/cli/commands/init.mjs +1 -1
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/cli/index.mjs +2 -2
- package/dist/context/index.mjs +2 -64
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -4
- package/dist/{core-b6fgFgnd.mjs → core-EUNu0u07.mjs} +298 -12
- package/dist/{createActionRouter-h1cEEaEX.mjs → createActionRouter-B6GvAuAX.mjs} +4 -3
- package/dist/{createAggregationRouter-B1C0kvTL.mjs → createAggregationRouter-wzwFa_dW.mjs} +3 -3
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-COpYKt2h.mjs → elevation-CIz1GjGm.mjs} +4 -3
- package/dist/{errorHandler-BTzXSwIg.mjs → errorHandler-Bq2OeEQL.mjs} +10 -7
- package/dist/{eventPlugin-A6EbthXR.d.mts → eventPlugin-Bg3cP2_u.d.mts} +38 -3
- package/dist/{eventPlugin-DJG2D7sy.mjs → eventPlugin-DrQ_FesK.mjs} +22 -4
- package/dist/events/index.d.mts +57 -314
- package/dist/events/index.mjs +168 -93
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +24 -2
- package/dist/factory/index.d.mts +21 -6
- package/dist/factory/index.mjs +58 -18
- package/dist/{fastify-augmentation-9_fuetB4.d.mts → fastify-augmentation-dPNBVeIj.d.mts} +25 -2
- package/dist/{generate-usRSdQmP.mjs → generate-D0wZupBg.mjs} +1 -1
- package/dist/headers-BvlQSDMB.mjs +45 -0
- package/dist/health-CB_m4S32.mjs +377 -0
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +7 -0
- package/dist/idempotency/index.mjs +32 -20
- package/dist/{index-ogIHvOjo.d.mts → index-BMnbzvtO.d.mts} +70 -4
- package/dist/{integrations/jobs.d.mts → index-CG4yqMDB.d.mts} +82 -2
- package/dist/{index-DbtgCOCj.d.mts → index-DPRH0Cme.d.mts} +46 -94
- package/dist/{module-B4u10iOJ.d.mts → index-KUoxjIq1.d.mts} +263 -23
- package/dist/{index-D8MKYGDa.d.mts → index-f4PTrk3B.d.mts} +37 -5
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +6 -5
- package/dist/{init-DleK3p9f.mjs → init-tlRSt667.mjs} +206 -205
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +3 -3
- package/dist/integrations/jobs/index.d.mts +2 -0
- package/dist/integrations/{jobs.mjs → jobs/index.mjs} +110 -67
- package/dist/integrations/mcp/index.d.mts +68 -5
- package/dist/integrations/mcp/index.mjs +2 -2
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/webhooks.d.mts +149 -22
- package/dist/integrations/webhooks.mjs +186 -42
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +1 -1
- package/dist/integrations/websocket.mjs +41 -14
- package/dist/{interface-C1_WLTMK.d.mts → interface-BGZCzOGL.d.mts} +24 -31
- package/dist/interface-n5hpKg1d.mjs +16 -0
- package/dist/{loadResourcesFromEntry-OMCvIEE7.mjs → loadResourcesFromEntry-kvGT6EcO.mjs} +1 -1
- package/dist/{mcpPlugin-ByA2tf6D.mjs → mcpPlugin-Cb7olw0j.mjs} +316 -45
- package/dist/{memory-CLXA2VeJ.mjs → memory-CmnzXE5Z.mjs} +29 -0
- package/dist/middleware/index.d.mts +17 -1
- package/dist/middleware/index.mjs +1 -1
- package/dist/migrations/index.d.mts +94 -44
- package/dist/migrations/index.mjs +196 -44
- package/dist/{multipartBody-CHEQSZ6G.mjs → multipartBody-D0eBvkeq.mjs} +17 -0
- package/dist/{openapi-BRpMZD7C.mjs → openapi-DrLtog_B.mjs} +4 -4
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +4 -3
- package/dist/{permissions-BJMuKMl7.mjs → permissions-BrtiBKGs.mjs} +305 -492
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/{actionPermissions-ze70xXJi.mjs → pipeline-BN5G05rd.mjs} +4 -27
- package/dist/plugins/index.d.mts +7 -78
- package/dist/plugins/index.mjs +14 -97
- package/dist/plugins/response-cache.mjs +1 -0
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/{index-BEu4wjbL.d.mts → preloadResources-0oe-JSSS.d.mts} +59 -50
- package/dist/{createApp-Ci7yG5gv.mjs → preloadResources-DfyngIN9.mjs} +240 -68
- package/dist/presets/filesUpload.d.mts +3 -3
- package/dist/presets/filesUpload.mjs +3 -3
- package/dist/presets/index.d.mts +2 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +1 -1
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-gw0ciIUD.mjs → presets-DwFDVKP0.mjs} +2 -2
- package/dist/{queryCachePlugin-DzqLN904.mjs → queryCachePlugin-DSGuSVLW.mjs} +39 -8
- package/dist/{queryCachePlugin-CfYWq-Ak.d.mts → queryCachePlugin-br4otiqF.d.mts} +2 -2
- package/dist/{registerRoute-xHc42fwB.mjs → rateLimit-DrED-C4v.mjs} +2 -109
- package/dist/realtime-Cuw_deA-.mjs +375 -0
- package/dist/{redis-stream-iVR2l7aS.d.mts → redis-stream-uIaauNpZ.d.mts} +22 -0
- package/dist/registerRoute-v0Wb6Plt.mjs +108 -0
- package/dist/registry/index.d.mts +2 -2
- package/dist/registry/index.mjs +1 -1
- package/dist/renewingLease-3cuQH7h8.mjs +44 -0
- package/dist/{requestContext-BZKOr13H.mjs → requestContext-MrlJwECf.mjs} +2 -2
- package/dist/schedules-CjOd2MG9.mjs +113 -0
- package/dist/{schemaConverter-Ds5reSGs.mjs → schemaConverter-DJvodiHj.mjs} +4 -1
- package/dist/{schemaIR-B-6SPe7g.mjs → schemaIR-Ca7ySNPx.mjs} +1 -1
- package/dist/schemas/index.d.mts +67 -67
- package/dist/schemas/index.mjs +2 -3
- package/dist/scim/index.mjs +1 -2
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +4 -4
- package/dist/scope-DfaPntpo.mjs +36 -0
- package/dist/simpleEqualityMatcher-Cg05HPsn.mjs +68 -0
- package/dist/{sse-CfUjwjDk.mjs → sse-BQ1w0dfl.mjs} +8 -31
- package/dist/sseStream-BQ7Qaynp.mjs +57 -0
- package/dist/testing/index.d.mts +4 -3
- package/dist/testing/index.mjs +2 -2
- package/dist/transactionContext-PNdoyLLm.mjs +64 -0
- package/dist/types/index.d.mts +4 -4
- package/dist/{types-bHblZ9YN.mjs → types-CSyCxl2V.mjs} +38 -7
- package/dist/{types-DH0GupI7.d.mts → types-DjC-KUCz.d.mts} +118 -20
- package/dist/{types-Csxga7d3.d.mts → types-Dmw-6Qeo.d.mts} +96 -4
- package/dist/{types-Bc42CUyk.d.mts → types-QFGc3Cjv.d.mts} +37 -2
- package/dist/usage/index.d.mts +3 -52
- package/dist/usage/index.mjs +0 -0
- package/dist/userHelpers-Cg_y3fBz.mjs +29 -0
- package/dist/utils/index.d.mts +90 -57
- package/dist/utils/index.mjs +7 -3
- package/dist/{utils-FhJ88TJE.mjs → utils-taUVD4p3.mjs} +47 -385
- package/dist/{validate-C2mGgSXg.mjs → validate-Bk44F2yT.mjs} +1 -1
- package/dist/{versioning-DLwklPlG.d.mts → versioning-DXgY5BUm.d.mts} +52 -31
- package/dist/{versioning-Dk-71iIz.mjs → versioning-gKWyryT3.mjs} +8 -1
- package/dist/{websocket-uuWqM6UY.d.mts → websocket-_Pd0CgR0.d.mts} +15 -1
- package/package.json +526 -453
- package/skills/arc/references/api-reference.md +3 -1
- package/skills/arc/references/integrations.md +20 -11
- package/skills/arc/references/production.md +734 -696
- package/dist/QueryCache-Z1B38koh.d.mts +0 -40
- package/dist/betterAuthOpenApi-CV1lxpRX.mjs +0 -2
- package/dist/health-ScyjIVo5.mjs +0 -202
- package/dist/requestId-5BhTVzBv.mjs +0 -113
- package/dist/scope-k15RXfcq.mjs +0 -64
- package/dist/types-XTApa8b6.mjs +0 -24
|
@@ -31,20 +31,127 @@ import fp from "fastify-plugin";
|
|
|
31
31
|
* ```
|
|
32
32
|
*/
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
34
|
+
* Signed-string layout for the v1 webhook contract:
|
|
35
35
|
*
|
|
36
|
-
*
|
|
36
|
+
* v1.<timestampMs>.<deliveryId>.<body>
|
|
37
|
+
*
|
|
38
|
+
* Binding the timestamp bounds replay to the verifier's tolerance window,
|
|
39
|
+
* and binding the delivery id lets receivers deduplicate redeliveries on an
|
|
40
|
+
* AUTHENTICATED value (the legacy `x-webhook-id` header was unsigned — an
|
|
41
|
+
* attacker replaying a captured request could change it and defeat dedup).
|
|
42
|
+
*
|
|
43
|
+
* Wire headers:
|
|
44
|
+
* - `x-arc-webhook-signature: v1=<hex hmac-sha256>`
|
|
45
|
+
* - `x-arc-webhook-timestamp: <unix epoch ms>`
|
|
46
|
+
* - `x-arc-webhook-delivery: <delivery id>` (arc sends the event's
|
|
47
|
+
* `meta.id` — STABLE across retries of the same event, so dedup on it
|
|
48
|
+
* collapses redeliveries)
|
|
37
49
|
*/
|
|
38
|
-
function
|
|
50
|
+
function signWebhook(body, secret, meta) {
|
|
39
51
|
const hmac = createHmac("sha256", secret);
|
|
40
|
-
hmac.update(
|
|
41
|
-
return `
|
|
52
|
+
hmac.update(`v1.${meta.timestamp}.${meta.deliveryId}.${body}`);
|
|
53
|
+
return `v1=${hmac.digest("hex")}`;
|
|
42
54
|
}
|
|
43
55
|
/**
|
|
44
|
-
* Verify an inbound webhook signature
|
|
56
|
+
* Verify an inbound v1 arc webhook: signature + timestamp tolerance, and
|
|
57
|
+
* return the authenticated delivery id for receiver-side deduplication.
|
|
45
58
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
59
|
+
* ⚠ Pass `req.rawBody` (route opted in via `config: { rawBody: true }`),
|
|
60
|
+
* never the parsed `req.body` — re-serialised JSON won't match the HMAC.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```typescript
|
|
64
|
+
* fastify.post('/hooks/arc', { config: { rawBody: true } }, async (req, reply) => {
|
|
65
|
+
* const result = verifyWebhook({
|
|
66
|
+
* body: req.rawBody,
|
|
67
|
+
* secret: process.env.WEBHOOK_SECRET,
|
|
68
|
+
* signature: req.headers['x-arc-webhook-signature'] as string,
|
|
69
|
+
* timestamp: req.headers['x-arc-webhook-timestamp'] as string,
|
|
70
|
+
* deliveryId: req.headers['x-arc-webhook-delivery'] as string,
|
|
71
|
+
* });
|
|
72
|
+
* if (!result.valid) return reply.status(401).send({ error: result.reason });
|
|
73
|
+
* if (await seen(result.deliveryId)) return reply.send({ ok: true }); // dedup
|
|
74
|
+
* // ... handle, then mark result.deliveryId seen
|
|
75
|
+
* });
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
function verifyWebhook(input) {
|
|
79
|
+
const { body, secret, signature, deliveryId, toleranceMs = 3e5, now = Date.now } = input;
|
|
80
|
+
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.");
|
|
81
|
+
if (!signature || !deliveryId || input.timestamp === void 0 || input.timestamp === "") return {
|
|
82
|
+
valid: false,
|
|
83
|
+
reason: "missing_headers"
|
|
84
|
+
};
|
|
85
|
+
const timestamp = Number(input.timestamp);
|
|
86
|
+
if (!Number.isFinite(timestamp)) return {
|
|
87
|
+
valid: false,
|
|
88
|
+
reason: "bad_timestamp"
|
|
89
|
+
};
|
|
90
|
+
if (Math.abs(now() - timestamp) > toleranceMs) return {
|
|
91
|
+
valid: false,
|
|
92
|
+
reason: "expired"
|
|
93
|
+
};
|
|
94
|
+
const expected = signWebhook(typeof body === "string" ? body : body.toString("utf8"), secret, {
|
|
95
|
+
timestamp,
|
|
96
|
+
deliveryId
|
|
97
|
+
});
|
|
98
|
+
const a = Buffer.from(signature);
|
|
99
|
+
const b = Buffer.from(expected);
|
|
100
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return {
|
|
101
|
+
valid: false,
|
|
102
|
+
reason: "bad_signature"
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
valid: true,
|
|
106
|
+
deliveryId,
|
|
107
|
+
timestamp
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Opt-in URL policy for tenant-registered webhooks: requires HTTPS and
|
|
112
|
+
* rejects loopback, link-local, private-network, and cloud-metadata
|
|
113
|
+
* destinations. Pass as `validateUrl: defaultWebhookUrlPolicy`.
|
|
114
|
+
*
|
|
115
|
+
* Scope note: this validates the URL LITERAL at registration time. It does
|
|
116
|
+
* not resolve DNS — rebinding/level-2 SSRF protection belongs at the actual
|
|
117
|
+
* outbound network boundary (egress proxy, network policy), where the real
|
|
118
|
+
* connection target is known. Internal deployments that legitimately POST
|
|
119
|
+
* to private services should write their own narrower policy instead.
|
|
120
|
+
*/
|
|
121
|
+
const BLOCKED_V4 = /^(?:127\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|169\.254\.|0\.)/;
|
|
122
|
+
function defaultWebhookUrlPolicy(url) {
|
|
123
|
+
if (url.protocol !== "https:") throw new Error(`Webhook URL must use https (got ${url.protocol}//)`);
|
|
124
|
+
let host = url.hostname.toLowerCase();
|
|
125
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
126
|
+
const blockedName = host === "localhost" || host.endsWith(".localhost") || host === "metadata.google.internal";
|
|
127
|
+
const isV6 = host.includes(":");
|
|
128
|
+
const mappedV4 = isV6 ? decodeMappedV4(host) : void 0;
|
|
129
|
+
const blockedV6 = isV6 && (host === "::1" || host === "::" || /^f[cd]/.test(host) || /^fe[89ab]/.test(host) || mappedV4 !== void 0 && BLOCKED_V4.test(mappedV4));
|
|
130
|
+
const blockedV4 = !isV6 && BLOCKED_V4.test(host);
|
|
131
|
+
if (blockedName || blockedV4 || blockedV6) throw new Error(`Webhook URL host '${host}' is not allowed (loopback/private/metadata)`);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Decode an IPv4-mapped IPv6 literal to its dotted-quad form. Accepts both
|
|
135
|
+
* the textual spelling (`::ffff:1.2.3.4`) and the hex-group canonical form
|
|
136
|
+
* WHATWG URL produces (`::ffff:102:304`). Returns undefined for anything else.
|
|
137
|
+
*/
|
|
138
|
+
function decodeMappedV4(host) {
|
|
139
|
+
const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(host)?.[1];
|
|
140
|
+
if (dotted) return dotted;
|
|
141
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
|
|
142
|
+
if (!hex?.[1] || !hex[2]) return void 0;
|
|
143
|
+
const hi = Number.parseInt(hex[1], 16);
|
|
144
|
+
const lo = Number.parseInt(hex[2], 16);
|
|
145
|
+
return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Verify an INBOUND third-party webhook signature using timing-safe
|
|
149
|
+
* comparison — for webhooks arc RECEIVES (GitHub, Stripe, ...), not for
|
|
150
|
+
* arc-to-arc delivery. Arc's own outbound contract is v1
|
|
151
|
+
* (`x-arc-webhook-*`) — receivers of arc webhooks use {@link verifyWebhook}.
|
|
152
|
+
*
|
|
153
|
+
* Defaults to the common `sha256=<hex>` format; configurable for any HMAC
|
|
154
|
+
* scheme via options.
|
|
48
155
|
*
|
|
49
156
|
* ⚠ The `body` argument must be the exact bytes the sender signed. Fastify
|
|
50
157
|
* parses JSON bodies by default and the re-serialised object will NOT match
|
|
@@ -62,21 +169,17 @@ function signPayload(payload, secret) {
|
|
|
62
169
|
* ```typescript
|
|
63
170
|
* import { verifySignature } from '@classytic/arc/integrations/webhooks';
|
|
64
171
|
*
|
|
65
|
-
* //
|
|
66
|
-
* //
|
|
67
|
-
* //
|
|
68
|
-
* //
|
|
69
|
-
*
|
|
70
|
-
*
|
|
172
|
+
* // `config: { rawBody: true }` is REQUIRED — arc registers
|
|
173
|
+
* // fastify-raw-body with `global: false`, so routes opt in per-route;
|
|
174
|
+
* // without it `req.rawBody` is undefined and verifySignature throws a
|
|
175
|
+
* // TypeError.
|
|
176
|
+
*
|
|
177
|
+
* // GitHub-style sender (sha256=<hex>)
|
|
178
|
+
* fastify.post('/webhooks/github', { config: { rawBody: true } }, async (req, reply) => {
|
|
179
|
+
* const sig = req.headers['x-hub-signature-256'] as string;
|
|
71
180
|
* if (!verifySignature(req.rawBody, secret, sig)) {
|
|
72
181
|
* return reply.status(401).send({ error: 'Invalid signature' });
|
|
73
182
|
* }
|
|
74
|
-
* // handle event via req.headers['x-webhook-event']
|
|
75
|
-
* });
|
|
76
|
-
*
|
|
77
|
-
* // Third-party sender with custom header + bare hex
|
|
78
|
-
* const valid = verifySignature(body, secret, req.headers['x-hub-signature'], {
|
|
79
|
-
* prefix: 'sha256=', // GitHub format
|
|
80
183
|
* });
|
|
81
184
|
*
|
|
82
185
|
* // Stripe-style (bare hex, different header)
|
|
@@ -133,9 +236,27 @@ const webhookPlugin = async (fastify, opts = {}) => {
|
|
|
133
236
|
const timeout = opts.timeout ?? 1e4;
|
|
134
237
|
const maxLogEntries = opts.maxLogEntries ?? 1e3;
|
|
135
238
|
const concurrency = opts.concurrency ?? 5;
|
|
239
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error(`webhookPlugin: concurrency must be an integer >= 1 (got ${String(opts.concurrency)})`);
|
|
240
|
+
const retryAttempts = opts.retry?.attempts ?? 1;
|
|
241
|
+
const retryBackoffMs = opts.retry?.backoffMs ?? 1e3;
|
|
242
|
+
if (!Number.isInteger(retryAttempts) || retryAttempts < 1) throw new Error(`webhookPlugin: retry.attempts must be an integer >= 1 (got ${String(opts.retry?.attempts)})`);
|
|
136
243
|
let subscriptions = [];
|
|
137
244
|
const log = [];
|
|
138
245
|
subscriptions = await store.getAll();
|
|
246
|
+
if (opts.validateUrl) {
|
|
247
|
+
const admitted = [];
|
|
248
|
+
for (const sub of subscriptions) try {
|
|
249
|
+
await opts.validateUrl(new URL(sub.url), sub);
|
|
250
|
+
admitted.push(sub);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
fastify.log.warn({
|
|
253
|
+
subscriptionId: sub.id,
|
|
254
|
+
url: sub.url,
|
|
255
|
+
err
|
|
256
|
+
}, "webhookPlugin: persisted subscription failed the URL policy — excluded from dispatch. Fix the URL or unregister the subscription.");
|
|
257
|
+
}
|
|
258
|
+
subscriptions = admitted;
|
|
259
|
+
}
|
|
139
260
|
async function dispatchEvent(event) {
|
|
140
261
|
const matching = subscriptions.filter((s) => matchesPattern(s.events, event.type));
|
|
141
262
|
if (matching.length === 0) return;
|
|
@@ -151,29 +272,42 @@ const webhookPlugin = async (fastify, opts = {}) => {
|
|
|
151
272
|
success: false,
|
|
152
273
|
timestamp: /* @__PURE__ */ new Date()
|
|
153
274
|
};
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
275
|
+
for (let attempt = 1; attempt <= retryAttempts; attempt++) {
|
|
276
|
+
record.attempts = attempt;
|
|
277
|
+
delete record.status;
|
|
278
|
+
delete record.error;
|
|
158
279
|
try {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
"x-webhook-signature": signature,
|
|
164
|
-
"x-webhook-id": event.meta.id,
|
|
165
|
-
"x-webhook-event": event.type
|
|
166
|
-
},
|
|
167
|
-
body,
|
|
168
|
-
signal: ac.signal
|
|
280
|
+
const timestamp = Date.now();
|
|
281
|
+
const v1Signature = signWebhook(body, sub.secret, {
|
|
282
|
+
timestamp,
|
|
283
|
+
deliveryId: event.meta.id
|
|
169
284
|
});
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
285
|
+
const ac = new AbortController();
|
|
286
|
+
const timer = setTimeout(() => ac.abort(), timeout);
|
|
287
|
+
try {
|
|
288
|
+
const response = await fetchFn(sub.url, {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: {
|
|
291
|
+
"content-type": "application/json",
|
|
292
|
+
"x-arc-webhook-signature": v1Signature,
|
|
293
|
+
"x-arc-webhook-timestamp": String(timestamp),
|
|
294
|
+
"x-arc-webhook-delivery": event.meta.id
|
|
295
|
+
},
|
|
296
|
+
body,
|
|
297
|
+
signal: ac.signal,
|
|
298
|
+
...opts.validateUrl ? { redirect: "manual" } : {}
|
|
299
|
+
});
|
|
300
|
+
record.success = response.ok;
|
|
301
|
+
record.status = response.status;
|
|
302
|
+
} finally {
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
}
|
|
305
|
+
} catch (err) {
|
|
306
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
174
307
|
}
|
|
175
|
-
|
|
176
|
-
record.
|
|
308
|
+
if (record.success) break;
|
|
309
|
+
if (!(record.status === void 0 || record.status === 429 || record.status >= 500) || attempt === retryAttempts) break;
|
|
310
|
+
await new Promise((r) => setTimeout(r, retryBackoffMs * 2 ** (attempt - 1)));
|
|
177
311
|
}
|
|
178
312
|
log.push(record);
|
|
179
313
|
if (log.length > maxLogEntries) log.splice(0, log.length - maxLogEntries);
|
|
@@ -188,6 +322,13 @@ const webhookPlugin = async (fastify, opts = {}) => {
|
|
|
188
322
|
if (fastify.events) unsubscribe = await fastify.events.subscribe("*", dispatchEvent);
|
|
189
323
|
fastify.decorate("webhooks", {
|
|
190
324
|
async register(sub) {
|
|
325
|
+
let parsed;
|
|
326
|
+
try {
|
|
327
|
+
parsed = new URL(sub.url);
|
|
328
|
+
} catch {
|
|
329
|
+
throw new Error(`webhookPlugin: invalid webhook URL '${sub.url}'`);
|
|
330
|
+
}
|
|
331
|
+
if (opts.validateUrl) await opts.validateUrl(parsed, sub);
|
|
191
332
|
await store.save(sub);
|
|
192
333
|
subscriptions = subscriptions.filter((s) => s.id !== sub.id);
|
|
193
334
|
subscriptions.push(sub);
|
|
@@ -197,7 +338,10 @@ const webhookPlugin = async (fastify, opts = {}) => {
|
|
|
197
338
|
subscriptions = subscriptions.filter((s) => s.id !== id);
|
|
198
339
|
},
|
|
199
340
|
list() {
|
|
200
|
-
return
|
|
341
|
+
return subscriptions.map(({ secret: _secret, ...pub }) => pub);
|
|
342
|
+
},
|
|
343
|
+
getSecret(id) {
|
|
344
|
+
return subscriptions.find((s) => s.id === id)?.secret;
|
|
201
345
|
},
|
|
202
346
|
deliveryLog(limit) {
|
|
203
347
|
if (limit) return log.slice(-limit);
|
|
@@ -217,4 +361,4 @@ var webhooks_default = fp(webhookPlugin, {
|
|
|
217
361
|
dependencies: ["arc-events"]
|
|
218
362
|
});
|
|
219
363
|
//#endregion
|
|
220
|
-
export { webhooks_default as default,
|
|
364
|
+
export { webhooks_default as default, defaultWebhookUrlPolicy, signWebhook, verifySignature, verifyWebhook, webhookPlugin };
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { a as GeoRoomOptions, c as WebSocketClient, d as LocalWebSocketAdapter, f as WebSocketAdapter, i as GeoRoom, l as WebSocketMessage, n as GeoPoint, o as RoomManager, r as GeoPublishOpts, s as AuthResult, t as websocketPlugin, u as WebSocketPluginOptions } from "../websocket-
|
|
1
|
+
import { a as GeoRoomOptions, c as WebSocketClient, d as LocalWebSocketAdapter, f as WebSocketAdapter, i as GeoRoom, l as WebSocketMessage, n as GeoPoint, o as RoomManager, r as GeoPublishOpts, s as AuthResult, t as websocketPlugin, u as WebSocketPluginOptions } from "../websocket-_Pd0CgR0.mjs";
|
|
2
2
|
import { a as PushRefRegistry, c as SerializedEntry, i as Principal, o as PushRefRegistryOptions, r as MemoryPushRefStore, s as PushRefStore } from "../pushref-registry-BbcnpNTm.mjs";
|
|
3
3
|
export { type AuthResult, type GeoPoint, type GeoPublishOpts, GeoRoom, type GeoRoomOptions, LocalWebSocketAdapter, MemoryPushRefStore, type Principal, PushRefRegistry, type PushRefRegistryOptions, type PushRefStore, RoomManager, type SerializedEntry, type WebSocketAdapter, type WebSocketClient, type WebSocketMessage, type WebSocketPluginOptions, websocketPlugin };
|
|
@@ -648,7 +648,15 @@ function safeAsync(p, logger, op, ctx) {
|
|
|
648
648
|
*/
|
|
649
649
|
var SendQueue = class {
|
|
650
650
|
entries = [];
|
|
651
|
-
/**
|
|
651
|
+
/**
|
|
652
|
+
* Logical queue head — `entries[head..]` is the live queue. Draining
|
|
653
|
+
* advances the pointer instead of `shift()`ing (which moves every
|
|
654
|
+
* trailing element AND invalidated every tracked coalesce index,
|
|
655
|
+
* making a full drain O(n²)). The prefix is garbage-collected by
|
|
656
|
+
* {@link compactIfNeeded} — amortized O(1) per drained frame.
|
|
657
|
+
*/
|
|
658
|
+
head = 0;
|
|
659
|
+
/** `coalesceKey → ABSOLUTE entries[] index` for O(1) replacement. */
|
|
652
660
|
coalesceIndex = /* @__PURE__ */ new Map();
|
|
653
661
|
socket;
|
|
654
662
|
capacity;
|
|
@@ -666,7 +674,7 @@ var SendQueue = class {
|
|
|
666
674
|
/** Ordered, guaranteed-best-effort delivery. Overflow ⇒ terminate. */
|
|
667
675
|
send(payload) {
|
|
668
676
|
if (this.disposed) return;
|
|
669
|
-
if (this.
|
|
677
|
+
if (this.size() >= this.capacity) {
|
|
670
678
|
this.terminate("Queue overflow");
|
|
671
679
|
return;
|
|
672
680
|
}
|
|
@@ -685,7 +693,7 @@ var SendQueue = class {
|
|
|
685
693
|
if (this.disposed) return;
|
|
686
694
|
if (coalesceKey !== void 0) {
|
|
687
695
|
const idx = this.coalesceIndex.get(coalesceKey);
|
|
688
|
-
if (idx !== void 0 && this.entries[idx]) {
|
|
696
|
+
if (idx !== void 0 && idx >= this.head && this.entries[idx]) {
|
|
689
697
|
this.entries[idx] = {
|
|
690
698
|
payload,
|
|
691
699
|
cls: "droppable",
|
|
@@ -695,12 +703,16 @@ var SendQueue = class {
|
|
|
695
703
|
return;
|
|
696
704
|
}
|
|
697
705
|
}
|
|
698
|
-
if (this.
|
|
699
|
-
|
|
706
|
+
if (this.size() >= this.capacity) {
|
|
707
|
+
let shedIdx = -1;
|
|
708
|
+
for (let i = this.head; i < this.entries.length; i++) if (this.entries[i]?.cls === "droppable") {
|
|
709
|
+
shedIdx = i;
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
700
712
|
if (shedIdx === -1) return;
|
|
701
713
|
const evicted = this.entries.splice(shedIdx, 1)[0];
|
|
702
714
|
if (evicted?.coalesceKey !== void 0) this.coalesceIndex.delete(evicted.coalesceKey);
|
|
703
|
-
this.
|
|
715
|
+
for (const [key, idx] of this.coalesceIndex) if (idx > shedIdx) this.coalesceIndex.set(key, idx - 1);
|
|
704
716
|
}
|
|
705
717
|
this.entries.push({
|
|
706
718
|
payload,
|
|
@@ -718,28 +730,44 @@ var SendQueue = class {
|
|
|
718
730
|
*/
|
|
719
731
|
flush() {
|
|
720
732
|
if (this.disposed) return;
|
|
721
|
-
while (this.
|
|
733
|
+
while (this.size() > 0 && this.socket.readyState === 1) {
|
|
722
734
|
if ((this.socket.bufferedAmount ?? 0) >= this.drainThreshold) {
|
|
723
735
|
this.scheduleRetry();
|
|
724
|
-
|
|
736
|
+
break;
|
|
725
737
|
}
|
|
726
|
-
const entry = this.entries.
|
|
738
|
+
const entry = this.entries[this.head];
|
|
727
739
|
if (!entry) return;
|
|
740
|
+
this.head++;
|
|
728
741
|
if (entry.coalesceKey !== void 0) this.coalesceIndex.delete(entry.coalesceKey);
|
|
729
|
-
if (this.coalesceIndex.size > 0) this.reindexCoalesceFrom(0);
|
|
730
742
|
try {
|
|
731
743
|
this.socket.send(entry.payload);
|
|
732
744
|
} catch {
|
|
733
745
|
return;
|
|
734
746
|
}
|
|
735
747
|
}
|
|
748
|
+
this.compactIfNeeded();
|
|
736
749
|
}
|
|
737
750
|
size() {
|
|
738
|
-
return this.entries.length;
|
|
751
|
+
return this.entries.length - this.head;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Drop the drained prefix once it dominates the array. Rebuilding the
|
|
755
|
+
* (small) coalesce index here is the amortized cost that replaces the
|
|
756
|
+
* per-frame O(n) reindex the old `shift()` drain paid.
|
|
757
|
+
*/
|
|
758
|
+
compactIfNeeded() {
|
|
759
|
+
if (this.head < 64 || this.head * 2 < this.entries.length) return;
|
|
760
|
+
this.entries = this.entries.slice(this.head);
|
|
761
|
+
this.head = 0;
|
|
762
|
+
if (this.coalesceIndex.size > 0) {
|
|
763
|
+
this.coalesceIndex.clear();
|
|
764
|
+
this.reindexCoalesceFrom(0);
|
|
765
|
+
}
|
|
739
766
|
}
|
|
740
767
|
dispose() {
|
|
741
768
|
this.disposed = true;
|
|
742
769
|
this.entries.length = 0;
|
|
770
|
+
this.head = 0;
|
|
743
771
|
this.coalesceIndex.clear();
|
|
744
772
|
if (this.retryTimer) {
|
|
745
773
|
clearTimeout(this.retryTimer);
|
|
@@ -829,7 +857,6 @@ async function handleConnection(ctx, socket, request) {
|
|
|
829
857
|
clientId: serviceClientId,
|
|
830
858
|
authMode: options.auth
|
|
831
859
|
});
|
|
832
|
-
options.messageEnvelope;
|
|
833
860
|
const urlPushRef = readPushRefFromRequest(request);
|
|
834
861
|
let pushRef;
|
|
835
862
|
let envelope;
|
|
@@ -1279,7 +1306,7 @@ var RoomManager = class {
|
|
|
1279
1306
|
for (const clientId of members) {
|
|
1280
1307
|
if (clientId === excludeClientId) continue;
|
|
1281
1308
|
const client = this.clients.get(clientId);
|
|
1282
|
-
if (
|
|
1309
|
+
if (client?.socket.readyState !== 1) continue;
|
|
1283
1310
|
if ((client.socket.bufferedAmount ?? 0) > WS_BACKPRESSURE_LIMIT) continue;
|
|
1284
1311
|
try {
|
|
1285
1312
|
client.socket.send(message);
|
|
@@ -1303,7 +1330,7 @@ var RoomManager = class {
|
|
|
1303
1330
|
if (!members) return;
|
|
1304
1331
|
for (const clientId of members) {
|
|
1305
1332
|
const client = this.clients.get(clientId);
|
|
1306
|
-
if (
|
|
1333
|
+
if (client?.socket.readyState !== 1) continue;
|
|
1307
1334
|
if (client.queue) client.queue.sendRealtime(message, coalesceKey);
|
|
1308
1335
|
else if ((client.socket.bufferedAmount ?? 0) <= WS_BACKPRESSURE_LIMIT) try {
|
|
1309
1336
|
client.socket.send(message);
|
|
@@ -1,34 +1,5 @@
|
|
|
1
|
+
import { CacheAdapter } from "@classytic/repo-core/cache";
|
|
1
2
|
//#region src/cache/interface.d.ts
|
|
2
|
-
/**
|
|
3
|
-
* Cache Store Interface — aligned with `@classytic/repo-core/cache.CacheAdapter`.
|
|
4
|
-
*
|
|
5
|
-
* Arc's cache layer speaks the same `get / set(ttlSeconds?) / del / clear(pattern?)`
|
|
6
|
-
* transport-level contract published by `@classytic/repo-core`. One Redis
|
|
7
|
-
* implementation drops into Arc's `QueryCache`, mongokit's cache plugin,
|
|
8
|
-
* sqlitekit's cache plugin, and every future kit without wrapper shims.
|
|
9
|
-
*
|
|
10
|
-
* Arc extends the bare adapter with two optional observability fields —
|
|
11
|
-
* `name` (for diagnostics) and `stats()` (for the response-cache plugin) —
|
|
12
|
-
* that are opt-in: consumers implementing only `CacheAdapter` still
|
|
13
|
-
* structurally satisfy `CacheStore`, so a raw repo-core adapter plugs
|
|
14
|
-
* directly into Arc.
|
|
15
|
-
*
|
|
16
|
-
* ## TTL unit
|
|
17
|
-
*
|
|
18
|
-
* `ttlSeconds`, not milliseconds. Matches Redis (`SET … EX seconds`) which
|
|
19
|
-
* is the dominant backend. `0` or `undefined` means no expiry; implementations
|
|
20
|
-
* may apply their own default.
|
|
21
|
-
*
|
|
22
|
-
* ## Not-found semantics
|
|
23
|
-
*
|
|
24
|
-
* `get()` returns `undefined` on miss / expired. Matches repo-core.
|
|
25
|
-
*
|
|
26
|
-
* ## Sync-or-async
|
|
27
|
-
*
|
|
28
|
-
* Method returns are `Promise<T> | T` — in-memory `Map` adapters can be
|
|
29
|
-
* synchronous; Redis adapters are async. Consumers always `await`, so
|
|
30
|
-
* sync values just short-circuit the microtask.
|
|
31
|
-
*/
|
|
32
3
|
interface CacheLogger {
|
|
33
4
|
warn(message: string, ...args: unknown[]): void;
|
|
34
5
|
error(message: string, ...args: unknown[]): void;
|
|
@@ -45,7 +16,14 @@ interface CacheStats {
|
|
|
45
16
|
/** Number of entries evicted since creation */
|
|
46
17
|
evictions: number;
|
|
47
18
|
}
|
|
48
|
-
|
|
19
|
+
/**
|
|
20
|
+
* `extends CacheAdapter` is load-bearing: repo-core owns the transport
|
|
21
|
+
* contract, and extending (instead of maintaining a structural copy) makes
|
|
22
|
+
* any drift between the two a COMPILE error in arc rather than a runtime
|
|
23
|
+
* incompatibility — e.g. an argument-order divergence on `increment` would
|
|
24
|
+
* make a conforming adapter misread a TTL as the increment amount.
|
|
25
|
+
*/
|
|
26
|
+
interface CacheStore<TValue = unknown> extends CacheAdapter {
|
|
49
27
|
/** Store name for logs/diagnostics. Optional to match repo-core's bare `CacheAdapter`. */
|
|
50
28
|
readonly name?: string;
|
|
51
29
|
/**
|
|
@@ -70,6 +48,21 @@ interface CacheStore<TValue = unknown> {
|
|
|
70
48
|
* need strict invalidation must check for its presence: `store.clear?.(pattern)`.
|
|
71
49
|
*/
|
|
72
50
|
clear?(pattern?: string): Promise<void> | void;
|
|
51
|
+
/**
|
|
52
|
+
* Atomic increment — adds `by` (default 1) to the integer at `key`,
|
|
53
|
+
* creating it with value `by` when absent; returns the NEW value.
|
|
54
|
+
* Signature is the canonical `@classytic/repo-core/cache.CacheAdapter`
|
|
55
|
+
* contract — Redis adapters map to `INCRBY` (+ `EXPIRE` when
|
|
56
|
+
* `ttlSeconds` is given).
|
|
57
|
+
*
|
|
58
|
+
* Optional — consumers feature-detect it. Arc's `QueryCache` uses it for
|
|
59
|
+
* version-based invalidation: with `increment`, concurrent bumps from
|
|
60
|
+
* multiple replicas are strictly monotonic and never collide; without it,
|
|
61
|
+
* QueryCache falls back to a read-modify-write that is monotonic within a
|
|
62
|
+
* process but can lose a concurrent replica's bump. Distributed hosts
|
|
63
|
+
* should use a store that implements this.
|
|
64
|
+
*/
|
|
65
|
+
increment?(key: string, by?: number, ttlSeconds?: number): Promise<number> | number;
|
|
73
66
|
/** Cache statistics for observability. Optional. */
|
|
74
67
|
stats?(): CacheStats;
|
|
75
68
|
}
|
|
@@ -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
|