@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.
Files changed (147) hide show
  1. package/dist/{BaseController-C6FlRWxF.mjs → BaseController--M8EMzt0.mjs} +92 -22
  2. package/dist/QueryCache-DOMT_iq7.d.mts +72 -0
  3. package/dist/{ResourceRegistry-7RnrEWNP.mjs → ResourceRegistry-y9Hnypus.mjs} +1 -0
  4. package/dist/actionPermissions-CAphcmUG.mjs +24 -0
  5. package/dist/applyPermissionResult-Tw4iXtlf.mjs +264 -0
  6. package/dist/audit/index.d.mts +32 -3
  7. package/dist/audit/index.mjs +7 -4
  8. package/dist/auth/index.d.mts +2 -2
  9. package/dist/auth/index.mjs +264 -260
  10. package/dist/{buildHandler-D_2ULWxZ.mjs → buildHandler-Dtwd8fdz.mjs} +1 -1
  11. package/dist/cache/index.d.mts +41 -3
  12. package/dist/cache/index.mjs +33 -4
  13. package/dist/cli/commands/describe.d.mts +1 -1
  14. package/dist/cli/commands/describe.mjs +1 -1
  15. package/dist/cli/commands/docs.mjs +2 -2
  16. package/dist/cli/commands/generate.mjs +1 -1
  17. package/dist/cli/commands/init.mjs +1 -1
  18. package/dist/cli/commands/introspect.mjs +1 -1
  19. package/dist/cli/index.mjs +2 -2
  20. package/dist/context/index.mjs +2 -64
  21. package/dist/core/index.d.mts +3 -3
  22. package/dist/core/index.mjs +4 -4
  23. package/dist/{core-b6fgFgnd.mjs → core-EUNu0u07.mjs} +298 -12
  24. package/dist/{createActionRouter-h1cEEaEX.mjs → createActionRouter-B6GvAuAX.mjs} +4 -3
  25. package/dist/{createAggregationRouter-B1C0kvTL.mjs → createAggregationRouter-wzwFa_dW.mjs} +3 -3
  26. package/dist/docs/index.d.mts +1 -1
  27. package/dist/docs/index.mjs +2 -2
  28. package/dist/{elevation-COpYKt2h.mjs → elevation-CIz1GjGm.mjs} +4 -3
  29. package/dist/{errorHandler-BTzXSwIg.mjs → errorHandler-Bq2OeEQL.mjs} +10 -7
  30. package/dist/{eventPlugin-A6EbthXR.d.mts → eventPlugin-Bg3cP2_u.d.mts} +38 -3
  31. package/dist/{eventPlugin-DJG2D7sy.mjs → eventPlugin-DrQ_FesK.mjs} +22 -4
  32. package/dist/events/index.d.mts +57 -314
  33. package/dist/events/index.mjs +168 -93
  34. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  35. package/dist/events/transports/redis-stream-entry.mjs +24 -2
  36. package/dist/factory/index.d.mts +21 -6
  37. package/dist/factory/index.mjs +58 -18
  38. package/dist/{fastify-augmentation-9_fuetB4.d.mts → fastify-augmentation-dPNBVeIj.d.mts} +25 -2
  39. package/dist/{generate-usRSdQmP.mjs → generate-D0wZupBg.mjs} +1 -1
  40. package/dist/headers-BvlQSDMB.mjs +45 -0
  41. package/dist/health-CB_m4S32.mjs +377 -0
  42. package/dist/hooks/index.d.mts +1 -1
  43. package/dist/idempotency/index.d.mts +7 -0
  44. package/dist/idempotency/index.mjs +32 -20
  45. package/dist/{index-ogIHvOjo.d.mts → index-BMnbzvtO.d.mts} +70 -4
  46. package/dist/{integrations/jobs.d.mts → index-CG4yqMDB.d.mts} +82 -2
  47. package/dist/{index-DbtgCOCj.d.mts → index-DPRH0Cme.d.mts} +46 -94
  48. package/dist/{module-B4u10iOJ.d.mts → index-KUoxjIq1.d.mts} +263 -23
  49. package/dist/{index-D8MKYGDa.d.mts → index-f4PTrk3B.d.mts} +37 -5
  50. package/dist/index.d.mts +6 -6
  51. package/dist/index.mjs +6 -5
  52. package/dist/{init-DleK3p9f.mjs → init-tlRSt667.mjs} +206 -205
  53. package/dist/integrations/event-gateway.d.mts +1 -1
  54. package/dist/integrations/event-gateway.mjs +1 -1
  55. package/dist/integrations/index.d.mts +3 -3
  56. package/dist/integrations/jobs/index.d.mts +2 -0
  57. package/dist/integrations/{jobs.mjs → jobs/index.mjs} +110 -67
  58. package/dist/integrations/mcp/index.d.mts +68 -5
  59. package/dist/integrations/mcp/index.mjs +2 -2
  60. package/dist/integrations/mcp/testing.d.mts +1 -1
  61. package/dist/integrations/mcp/testing.mjs +1 -1
  62. package/dist/integrations/webhooks.d.mts +149 -22
  63. package/dist/integrations/webhooks.mjs +186 -42
  64. package/dist/integrations/websocket-redis.d.mts +1 -1
  65. package/dist/integrations/websocket.d.mts +1 -1
  66. package/dist/integrations/websocket.mjs +41 -14
  67. package/dist/{interface-C1_WLTMK.d.mts → interface-BGZCzOGL.d.mts} +24 -31
  68. package/dist/interface-n5hpKg1d.mjs +16 -0
  69. package/dist/{loadResourcesFromEntry-OMCvIEE7.mjs → loadResourcesFromEntry-kvGT6EcO.mjs} +1 -1
  70. package/dist/{mcpPlugin-ByA2tf6D.mjs → mcpPlugin-Cb7olw0j.mjs} +316 -45
  71. package/dist/{memory-CLXA2VeJ.mjs → memory-CmnzXE5Z.mjs} +29 -0
  72. package/dist/middleware/index.d.mts +17 -1
  73. package/dist/middleware/index.mjs +1 -1
  74. package/dist/migrations/index.d.mts +94 -44
  75. package/dist/migrations/index.mjs +196 -44
  76. package/dist/{multipartBody-CHEQSZ6G.mjs → multipartBody-D0eBvkeq.mjs} +17 -0
  77. package/dist/{openapi-BRpMZD7C.mjs → openapi-DrLtog_B.mjs} +4 -4
  78. package/dist/permissions/index.d.mts +3 -3
  79. package/dist/permissions/index.mjs +4 -3
  80. package/dist/{permissions-BJMuKMl7.mjs → permissions-BrtiBKGs.mjs} +305 -492
  81. package/dist/pipeline/index.d.mts +1 -1
  82. package/dist/{actionPermissions-ze70xXJi.mjs → pipeline-BN5G05rd.mjs} +4 -27
  83. package/dist/plugins/index.d.mts +7 -78
  84. package/dist/plugins/index.mjs +14 -97
  85. package/dist/plugins/response-cache.mjs +1 -0
  86. package/dist/plugins/tracing-entry.mjs +1 -1
  87. package/dist/{index-BEu4wjbL.d.mts → preloadResources-0oe-JSSS.d.mts} +59 -50
  88. package/dist/{createApp-Ci7yG5gv.mjs → preloadResources-DfyngIN9.mjs} +240 -68
  89. package/dist/presets/filesUpload.d.mts +3 -3
  90. package/dist/presets/filesUpload.mjs +3 -3
  91. package/dist/presets/index.d.mts +2 -1
  92. package/dist/presets/index.mjs +1 -1
  93. package/dist/presets/multiTenant.d.mts +1 -1
  94. package/dist/presets/multiTenant.mjs +1 -1
  95. package/dist/presets/search.d.mts +2 -2
  96. package/dist/presets/search.mjs +1 -1
  97. package/dist/{presets-gw0ciIUD.mjs → presets-DwFDVKP0.mjs} +2 -2
  98. package/dist/{queryCachePlugin-DzqLN904.mjs → queryCachePlugin-DSGuSVLW.mjs} +39 -8
  99. package/dist/{queryCachePlugin-CfYWq-Ak.d.mts → queryCachePlugin-br4otiqF.d.mts} +2 -2
  100. package/dist/{registerRoute-xHc42fwB.mjs → rateLimit-DrED-C4v.mjs} +2 -109
  101. package/dist/realtime-Cuw_deA-.mjs +375 -0
  102. package/dist/{redis-stream-iVR2l7aS.d.mts → redis-stream-uIaauNpZ.d.mts} +22 -0
  103. package/dist/registerRoute-v0Wb6Plt.mjs +108 -0
  104. package/dist/registry/index.d.mts +2 -2
  105. package/dist/registry/index.mjs +1 -1
  106. package/dist/renewingLease-3cuQH7h8.mjs +44 -0
  107. package/dist/{requestContext-BZKOr13H.mjs → requestContext-MrlJwECf.mjs} +2 -2
  108. package/dist/schedules-CjOd2MG9.mjs +113 -0
  109. package/dist/{schemaConverter-Ds5reSGs.mjs → schemaConverter-DJvodiHj.mjs} +4 -1
  110. package/dist/{schemaIR-B-6SPe7g.mjs → schemaIR-Ca7ySNPx.mjs} +1 -1
  111. package/dist/schemas/index.d.mts +67 -67
  112. package/dist/schemas/index.mjs +2 -3
  113. package/dist/scim/index.mjs +1 -2
  114. package/dist/scope/index.d.mts +2 -2
  115. package/dist/scope/index.mjs +4 -4
  116. package/dist/scope-DfaPntpo.mjs +36 -0
  117. package/dist/simpleEqualityMatcher-Cg05HPsn.mjs +68 -0
  118. package/dist/{sse-CfUjwjDk.mjs → sse-BQ1w0dfl.mjs} +8 -31
  119. package/dist/sseStream-BQ7Qaynp.mjs +57 -0
  120. package/dist/testing/index.d.mts +4 -3
  121. package/dist/testing/index.mjs +2 -2
  122. package/dist/transactionContext-PNdoyLLm.mjs +64 -0
  123. package/dist/types/index.d.mts +4 -4
  124. package/dist/{types-bHblZ9YN.mjs → types-CSyCxl2V.mjs} +38 -7
  125. package/dist/{types-DH0GupI7.d.mts → types-DjC-KUCz.d.mts} +118 -20
  126. package/dist/{types-Csxga7d3.d.mts → types-Dmw-6Qeo.d.mts} +96 -4
  127. package/dist/{types-Bc42CUyk.d.mts → types-QFGc3Cjv.d.mts} +37 -2
  128. package/dist/usage/index.d.mts +3 -52
  129. package/dist/usage/index.mjs +0 -0
  130. package/dist/userHelpers-Cg_y3fBz.mjs +29 -0
  131. package/dist/utils/index.d.mts +90 -57
  132. package/dist/utils/index.mjs +7 -3
  133. package/dist/{utils-FhJ88TJE.mjs → utils-taUVD4p3.mjs} +47 -385
  134. package/dist/{validate-C2mGgSXg.mjs → validate-Bk44F2yT.mjs} +1 -1
  135. package/dist/{versioning-DLwklPlG.d.mts → versioning-DXgY5BUm.d.mts} +52 -31
  136. package/dist/{versioning-Dk-71iIz.mjs → versioning-gKWyryT3.mjs} +8 -1
  137. package/dist/{websocket-uuWqM6UY.d.mts → websocket-_Pd0CgR0.d.mts} +15 -1
  138. package/package.json +526 -453
  139. package/skills/arc/references/api-reference.md +3 -1
  140. package/skills/arc/references/integrations.md +20 -11
  141. package/skills/arc/references/production.md +734 -696
  142. package/dist/QueryCache-Z1B38koh.d.mts +0 -40
  143. package/dist/betterAuthOpenApi-CV1lxpRX.mjs +0 -2
  144. package/dist/health-ScyjIVo5.mjs +0 -202
  145. package/dist/requestId-5BhTVzBv.mjs +0 -113
  146. package/dist/scope-k15RXfcq.mjs +0 -64
  147. package/dist/types-XTApa8b6.mjs +0 -24
@@ -31,20 +31,127 @@ import fp from "fastify-plugin";
31
31
  * ```
32
32
  */
33
33
  /**
34
- * Sign a payload with HMAC-SHA256 for outbound webhook delivery.
34
+ * Signed-string layout for the v1 webhook contract:
35
35
  *
36
- * @returns `sha256=<hex>` — the format written to `x-webhook-signature`
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 signPayload(payload, secret) {
50
+ function signWebhook(body, secret, meta) {
39
51
  const hmac = createHmac("sha256", secret);
40
- hmac.update(payload);
41
- return `sha256=${hmac.digest("hex")}`;
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 using timing-safe comparison.
56
+ * Verify an inbound v1 arc webhook: signature + timestamp tolerance, and
57
+ * return the authenticated delivery id for receiver-side deduplication.
45
58
  *
46
- * Works with Arc's own `signPayload` format by default (`sha256=<hex>`),
47
- * but configurable for any HMAC scheme via options.
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
- * // Arc-to-Arc (default headers + format). `config: { rawBody: true }` is
66
- * // REQUIRED — arc registers fastify-raw-body with `global: false`, so
67
- * // routes opt in per-route; without it `req.rawBody` is undefined and
68
- * // verifySignature throws a TypeError.
69
- * fastify.post('/webhooks/incoming', { config: { rawBody: true } }, async (req, reply) => {
70
- * const sig = req.headers['x-webhook-signature'] as string;
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
- try {
155
- const signature = signPayload(body, sub.secret);
156
- const ac = new AbortController();
157
- const timer = setTimeout(() => ac.abort(), timeout);
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 response = await fetchFn(sub.url, {
160
- method: "POST",
161
- headers: {
162
- "content-type": "application/json",
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
- record.success = response.ok;
171
- record.status = response.status;
172
- } finally {
173
- clearTimeout(timer);
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
- } catch (err) {
176
- record.error = err instanceof Error ? err.message : String(err);
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 [...subscriptions];
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, signPayload, verifySignature, webhookPlugin };
364
+ export { webhooks_default as default, defaultWebhookUrlPolicy, signWebhook, verifySignature, verifyWebhook, webhookPlugin };
@@ -1,4 +1,4 @@
1
- import { f as WebSocketAdapter } from "../websocket-uuWqM6UY.mjs";
1
+ import { f as WebSocketAdapter } from "../websocket-_Pd0CgR0.mjs";
2
2
  //#region src/integrations/websocket-redis.d.ts
3
3
  interface RedisLike {
4
4
  publish(channel: string, message: string): Promise<number>;
@@ -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-uuWqM6UY.mjs";
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
- /** `coalesceKey → entries[] index` for O(1) replacement. */
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.entries.length >= this.capacity) {
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.entries.length >= this.capacity) {
699
- const shedIdx = this.entries.findIndex((e) => e.cls === "droppable");
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.reindexCoalesceFrom(shedIdx);
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.entries.length > 0 && this.socket.readyState === 1) {
733
+ while (this.size() > 0 && this.socket.readyState === 1) {
722
734
  if ((this.socket.bufferedAmount ?? 0) >= this.drainThreshold) {
723
735
  this.scheduleRetry();
724
- return;
736
+ break;
725
737
  }
726
- const entry = this.entries.shift();
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 (!client || client.socket.readyState !== 1) continue;
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 (!client || client.socket.readyState !== 1) continue;
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
- interface CacheStore<TValue = unknown> {
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-7RnrEWNP.mjs";
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