@vinaverse/server 0.0.0-stage → 0.1.0-alpha.2
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/LICENSE +202 -0
- package/README.md +165 -2
- package/dist/api.cjs +528 -0
- package/dist/api.cjs.map +1 -0
- package/dist/api.d.cts +453 -0
- package/dist/api.d.ts +453 -0
- package/dist/api.js +516 -0
- package/dist/api.js.map +1 -0
- package/dist/chunk-FEEJNQXV.js +64 -0
- package/dist/chunk-FEEJNQXV.js.map +1 -0
- package/dist/chunk-GTPMQKGX.js +277 -0
- package/dist/chunk-GTPMQKGX.js.map +1 -0
- package/dist/chunk-NPXO6SMZ.js +84 -0
- package/dist/chunk-NPXO6SMZ.js.map +1 -0
- package/dist/chunk-SDJFZPHJ.js +141 -0
- package/dist/chunk-SDJFZPHJ.js.map +1 -0
- package/dist/express.cjs +397 -0
- package/dist/express.cjs.map +1 -0
- package/dist/express.d.cts +43 -0
- package/dist/express.d.ts +43 -0
- package/dist/express.js +87 -0
- package/dist/express.js.map +1 -0
- package/dist/fastify.cjs +363 -0
- package/dist/fastify.cjs.map +1 -0
- package/dist/fastify.d.cts +62 -0
- package/dist/fastify.d.ts +62 -0
- package/dist/fastify.js +54 -0
- package/dist/fastify.js.map +1 -0
- package/dist/hono.cjs +421 -0
- package/dist/hono.cjs.map +1 -0
- package/dist/hono.d.cts +21 -0
- package/dist/hono.d.ts +21 -0
- package/dist/hono.js +34 -0
- package/dist/hono.js.map +1 -0
- package/dist/index.cjs +1481 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +196 -0
- package/dist/index.d.ts +196 -0
- package/dist/index.js +920 -0
- package/dist/index.js.map +1 -0
- package/dist/lambda.cjs +405 -0
- package/dist/lambda.cjs.map +1 -0
- package/dist/lambda.d.cts +53 -0
- package/dist/lambda.d.ts +53 -0
- package/dist/lambda.js +86 -0
- package/dist/lambda.js.map +1 -0
- package/dist/nonces-scwljZwl.d.cts +92 -0
- package/dist/nonces-scwljZwl.d.ts +92 -0
- package/dist/provider-BqfTvF3g.d.ts +2016 -0
- package/dist/provider-Miaa9V7I.d.cts +2016 -0
- package/dist/stores.cjs +315 -0
- package/dist/stores.cjs.map +1 -0
- package/dist/stores.d.cts +52 -0
- package/dist/stores.d.ts +52 -0
- package/dist/stores.js +306 -0
- package/dist/stores.js.map +1 -0
- package/dist/testing.cjs +135 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +29 -0
- package/dist/testing.d.ts +29 -0
- package/dist/testing.js +49 -0
- package/dist/testing.js.map +1 -0
- package/dist/vinaverse-v1.d-BVc4uyUd.d.cts +3460 -0
- package/dist/vinaverse-v1.d-BVc4uyUd.d.ts +3460 -0
- package/package.json +225 -3
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { verifyWebhook, signingSecrets, checkTolerance } from './chunk-GTPMQKGX.js';
|
|
2
|
+
|
|
3
|
+
// src/adapters/handler.ts
|
|
4
|
+
var MAX_BODY_BYTES = 1024 * 1024;
|
|
5
|
+
var RECEIVED = JSON.stringify({ received: true });
|
|
6
|
+
function tooLarge() {
|
|
7
|
+
return {
|
|
8
|
+
status: 413,
|
|
9
|
+
body: JSON.stringify({ error: { code: "body_too_large" } })
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function byteLength(body) {
|
|
13
|
+
return typeof body === "string" ? new TextEncoder().encode(body).length : body.byteLength;
|
|
14
|
+
}
|
|
15
|
+
async function handleWebhook(options, rawBody, header) {
|
|
16
|
+
if (byteLength(rawBody) > (options.maxBodyBytes ?? MAX_BODY_BYTES)) {
|
|
17
|
+
return tooLarge();
|
|
18
|
+
}
|
|
19
|
+
const result = await verifyWebhook({
|
|
20
|
+
rawBody,
|
|
21
|
+
header,
|
|
22
|
+
secret: options.secret,
|
|
23
|
+
...options.now ? { now: options.now() } : {},
|
|
24
|
+
...options.toleranceSeconds === void 0 ? {} : { toleranceSeconds: options.toleranceSeconds }
|
|
25
|
+
});
|
|
26
|
+
if (!result.ok) {
|
|
27
|
+
await options.onRejected?.(result.error);
|
|
28
|
+
return {
|
|
29
|
+
status: 400,
|
|
30
|
+
body: JSON.stringify({ error: { code: result.error.code } })
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (result.kind === "event") await options.onEvent(result.event);
|
|
34
|
+
else if (result.kind === "test") await options.onTest?.(result.envelope);
|
|
35
|
+
else await options.onUnrecognized?.(result.envelope);
|
|
36
|
+
return { status: 200, body: RECEIVED };
|
|
37
|
+
}
|
|
38
|
+
function headerValue(headers, name) {
|
|
39
|
+
if (!headers) return void 0;
|
|
40
|
+
const wanted = name.toLowerCase();
|
|
41
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
42
|
+
if (key.toLowerCase() === wanted && value !== void 0) return value;
|
|
43
|
+
}
|
|
44
|
+
return void 0;
|
|
45
|
+
}
|
|
46
|
+
function checkHandlerOptions(options) {
|
|
47
|
+
signingSecrets(options.secret);
|
|
48
|
+
checkTolerance(options.toleranceSeconds);
|
|
49
|
+
if (typeof options.onEvent !== "function") {
|
|
50
|
+
throw new TypeError(
|
|
51
|
+
"onEvent is required: it receives every verified event"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const max = options.maxBodyBytes;
|
|
55
|
+
if (max !== void 0 && (!Number.isSafeInteger(max) || max <= 0)) {
|
|
56
|
+
throw new TypeError(
|
|
57
|
+
`maxBodyBytes must be a positive whole number of bytes, not ${max}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { MAX_BODY_BYTES, checkHandlerOptions, handleWebhook, headerValue, tooLarge };
|
|
63
|
+
//# sourceMappingURL=chunk-FEEJNQXV.js.map
|
|
64
|
+
//# sourceMappingURL=chunk-FEEJNQXV.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/adapters/handler.ts"],"names":[],"mappings":"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","file":"chunk-FEEJNQXV.js","sourcesContent":["// What every adapter does with a delivery once it has the raw body and the\n// signature header: verify, call the tenant's handler for the result, and\n// answer. A refused delivery is answered 400 (Vinaverse retries, then gives\n// up); a handled one 200. A handler that throws is the adapter's to surface,\n// so the delivery fails and Vinaverse retries it.\nimport type { WebhookError } from '../webhooks/errors.js';\nimport type { WebhookEnvelope, WebhookEvent } from '../webhooks/events.js';\nimport {\n checkTolerance,\n signingSecrets,\n verifyWebhook,\n} from '../webhooks/verify.js';\n\nexport interface WebhookHandlerOptions {\n /** The endpoint's signing secret, or two while rolling a new one out. */\n secret: string | readonly string[];\n /**\n * Called once per verified delivery of a known event. Answer quickly (the\n * contract allows 5 seconds) and do the work afterwards; deliveries are at\n * least once, so deduplicate on `event.id`.\n */\n onEvent(event: WebhookEvent): void | Promise<void>;\n /** A test delivery from Studio; by default it is acknowledged and ignored. */\n onTest?(envelope: WebhookEnvelope): void | Promise<void>;\n /** A verified type newer than this package; by default acknowledged and ignored. */\n onUnrecognized?(envelope: WebhookEnvelope): void | Promise<void>;\n /** A refused delivery, for logging; it is answered 400 either way. */\n onRejected?(error: WebhookError): void | Promise<void>;\n /** How far from now a signature may be, in seconds; default 300. */\n toleranceSeconds?: number;\n /**\n * The largest body accepted, in bytes; default 1 MiB (a conversation\n * webhook is at most 64 KB). A larger one is answered 413 unread.\n */\n maxBodyBytes?: number;\n /** The clock; default the system clock. */\n now?: () => Date;\n}\n\nexport interface WebhookResponse {\n readonly status: 200 | 400 | 413;\n readonly body: string;\n}\n\nexport const MAX_BODY_BYTES = 1024 * 1024;\nconst RECEIVED = JSON.stringify({ received: true });\n\nexport function tooLarge(): WebhookResponse {\n return {\n status: 413,\n body: JSON.stringify({ error: { code: 'body_too_large' } }),\n };\n}\n\nexport function byteLength(body: string | Uint8Array | ArrayBuffer): number {\n return typeof body === 'string'\n ? new TextEncoder().encode(body).length\n : body.byteLength;\n}\n\nexport async function handleWebhook(\n options: WebhookHandlerOptions,\n rawBody: string | Uint8Array | ArrayBuffer,\n header: string | readonly string[] | null | undefined,\n): Promise<WebhookResponse> {\n if (byteLength(rawBody) > (options.maxBodyBytes ?? MAX_BODY_BYTES)) {\n return tooLarge();\n }\n const result = await verifyWebhook({\n rawBody,\n header,\n secret: options.secret,\n ...(options.now ? { now: options.now() } : {}),\n ...(options.toleranceSeconds === undefined\n ? {}\n : { toleranceSeconds: options.toleranceSeconds }),\n });\n if (!result.ok) {\n await options.onRejected?.(result.error);\n return {\n status: 400,\n body: JSON.stringify({ error: { code: result.error.code } }),\n };\n }\n if (result.kind === 'event') await options.onEvent(result.event);\n else if (result.kind === 'test') await options.onTest?.(result.envelope);\n else await options.onUnrecognized?.(result.envelope);\n return { status: 200, body: RECEIVED };\n}\n\n/** A header's value from a record whose names may have any case. */\nexport function headerValue(\n headers:\n | Readonly<Record<string, string | readonly string[] | undefined>>\n | null\n | undefined,\n name: string,\n): string | readonly string[] | undefined {\n if (!headers) return undefined;\n const wanted = name.toLowerCase();\n for (const [key, value] of Object.entries(headers)) {\n if (key.toLowerCase() === wanted && value !== undefined) return value;\n }\n return undefined;\n}\n\n/**\n * Checks a handler's options once, when the adapter is made, so a wrong value\n * fails at start-up instead of answering every delivery with an error.\n */\nexport function checkHandlerOptions(options: WebhookHandlerOptions): void {\n signingSecrets(options.secret);\n checkTolerance(options.toleranceSeconds);\n if (typeof options.onEvent !== 'function') {\n throw new TypeError(\n 'onEvent is required: it receives every verified event',\n );\n }\n const max = options.maxBodyBytes;\n if (max !== undefined && (!Number.isSafeInteger(max) || max <= 0)) {\n throw new TypeError(\n `maxBodyBytes must be a positive whole number of bytes, not ${max}`,\n );\n }\n}\n"]}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// src/webhooks/crypto.ts
|
|
2
|
+
var WebCryptoUnavailableError = class extends Error {
|
|
3
|
+
constructor() {
|
|
4
|
+
super(
|
|
5
|
+
"Web Crypto (crypto.subtle) is not available in this runtime; @vinaverse/server needs Node 18+, an edge runtime or another runtime with Web Crypto"
|
|
6
|
+
);
|
|
7
|
+
this.name = "WebCryptoUnavailableError";
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var fromNode;
|
|
11
|
+
function webCrypto() {
|
|
12
|
+
const global = globalThis.crypto;
|
|
13
|
+
if (global?.subtle) return Promise.resolve(global);
|
|
14
|
+
fromNode ??= nodeWebCrypto();
|
|
15
|
+
return fromNode;
|
|
16
|
+
}
|
|
17
|
+
async function subtleCrypto() {
|
|
18
|
+
return (await webCrypto()).subtle;
|
|
19
|
+
}
|
|
20
|
+
async function nodeWebCrypto() {
|
|
21
|
+
if (typeof globalThis.process?.versions?.node !== "string") {
|
|
22
|
+
throw new WebCryptoUnavailableError();
|
|
23
|
+
}
|
|
24
|
+
const specifier = ["node", "crypto"].join(":");
|
|
25
|
+
const mod = await import(
|
|
26
|
+
/* webpackIgnore: true */
|
|
27
|
+
/* @vite-ignore */
|
|
28
|
+
specifier
|
|
29
|
+
);
|
|
30
|
+
if (!mod.webcrypto?.subtle) throw new WebCryptoUnavailableError();
|
|
31
|
+
return mod.webcrypto;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/internal/bytes.ts
|
|
35
|
+
var encoder = new TextEncoder();
|
|
36
|
+
function utf8(text) {
|
|
37
|
+
return encoder.encode(text);
|
|
38
|
+
}
|
|
39
|
+
function toBytes(body) {
|
|
40
|
+
if (typeof body === "string") return utf8(body);
|
|
41
|
+
if (body instanceof Uint8Array) return body;
|
|
42
|
+
return new Uint8Array(body);
|
|
43
|
+
}
|
|
44
|
+
function hexToBytes(hex) {
|
|
45
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
46
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
47
|
+
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
48
|
+
}
|
|
49
|
+
return bytes;
|
|
50
|
+
}
|
|
51
|
+
function bytesToHex(bytes) {
|
|
52
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
53
|
+
}
|
|
54
|
+
async function sha256Hex(data) {
|
|
55
|
+
const subtle = await subtleCrypto();
|
|
56
|
+
return bytesToHex(new Uint8Array(await subtle.digest("SHA-256", copy(data))));
|
|
57
|
+
}
|
|
58
|
+
async function hmacKey(secret, usage) {
|
|
59
|
+
const subtle = await subtleCrypto();
|
|
60
|
+
return subtle.importKey(
|
|
61
|
+
"raw",
|
|
62
|
+
utf8(secret),
|
|
63
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
64
|
+
false,
|
|
65
|
+
[usage]
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
function copy(data) {
|
|
69
|
+
const out = new Uint8Array(data.length);
|
|
70
|
+
out.set(data);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function concat(...parts) {
|
|
74
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
75
|
+
let at = 0;
|
|
76
|
+
for (const p of parts) {
|
|
77
|
+
out.set(p, at);
|
|
78
|
+
at += p.length;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/webhooks/errors.ts
|
|
84
|
+
function webhookError(code, message) {
|
|
85
|
+
return { code, message };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/webhooks/events.ts
|
|
89
|
+
var WEBHOOK_EVENT_TYPES = [
|
|
90
|
+
"moderation.item.completed",
|
|
91
|
+
"moderation.job.completed",
|
|
92
|
+
"document_check.completed",
|
|
93
|
+
"session.completed",
|
|
94
|
+
"subject.erased",
|
|
95
|
+
"usage.threshold_reached",
|
|
96
|
+
"feature.state_changed",
|
|
97
|
+
"media.job.queued",
|
|
98
|
+
"media.job.progress",
|
|
99
|
+
"media.job.step.completed",
|
|
100
|
+
"media.job.completed",
|
|
101
|
+
"media.job.failed",
|
|
102
|
+
"media.job.cancelled",
|
|
103
|
+
"conversation.updated",
|
|
104
|
+
"conversation.completed",
|
|
105
|
+
"conversation.failed",
|
|
106
|
+
"conversation.expired"
|
|
107
|
+
];
|
|
108
|
+
var KNOWN = new Set(WEBHOOK_EVENT_TYPES);
|
|
109
|
+
var CONVERSATION_STATUSES = {
|
|
110
|
+
"conversation.updated": ["in_progress", "failed"],
|
|
111
|
+
"conversation.completed": ["completed"],
|
|
112
|
+
"conversation.failed": ["failed"],
|
|
113
|
+
"conversation.expired": ["expired"]
|
|
114
|
+
};
|
|
115
|
+
var ENVIRONMENTS = /* @__PURE__ */ new Set(["mock", "development", "production"]);
|
|
116
|
+
function isConversationEvent(event) {
|
|
117
|
+
return event.type.startsWith("conversation.");
|
|
118
|
+
}
|
|
119
|
+
function isObject(value) {
|
|
120
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
121
|
+
}
|
|
122
|
+
function readBody(text) {
|
|
123
|
+
let parsed;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(text);
|
|
126
|
+
} catch {
|
|
127
|
+
return webhookError("invalid_body", "the body is not JSON");
|
|
128
|
+
}
|
|
129
|
+
if (!isObject(parsed) || typeof parsed.id !== "string" || typeof parsed.type !== "string" || typeof parsed.created_at !== "string" || typeof parsed.environment !== "string" || !ENVIRONMENTS.has(parsed.environment) || !isObject(parsed.data)) {
|
|
130
|
+
return webhookError(
|
|
131
|
+
"invalid_body",
|
|
132
|
+
"the body is not a webhook envelope (id, type, created_at, environment, data)"
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const envelope = parsed;
|
|
136
|
+
if (parsed.data.test === true) return { kind: "test", envelope };
|
|
137
|
+
if (!KNOWN.has(parsed.type)) return { kind: "unrecognized", envelope };
|
|
138
|
+
const statuses = CONVERSATION_STATUSES[parsed.type];
|
|
139
|
+
if (statuses) {
|
|
140
|
+
const { conversation_id, sequence, status, flow } = parsed.data;
|
|
141
|
+
if (typeof conversation_id !== "string" || !Number.isSafeInteger(sequence) || sequence < 1 || typeof status !== "string" || !statuses.includes(status) || typeof flow !== "string") {
|
|
142
|
+
return webhookError(
|
|
143
|
+
"invalid_body",
|
|
144
|
+
`a ${parsed.type} event carries a guided flow's state: conversation_id, a positive integer sequence, a flow, and status ${statuses.join(" or ")}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { kind: "event", event: parsed };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/webhooks/signature.ts
|
|
152
|
+
var SIGNATURE_HEADER = "Vinaverse-Signature";
|
|
153
|
+
var TOLERANCE_SECONDS = 300;
|
|
154
|
+
var DIGITS = /^[0-9]+$/;
|
|
155
|
+
var V1 = /^[0-9a-f]{64}$/;
|
|
156
|
+
function parseSignatureHeader(header) {
|
|
157
|
+
const parts = header.split(",").map((p) => p.trim()).filter((p) => p.includes("=")).map((p) => {
|
|
158
|
+
const at = p.indexOf("=");
|
|
159
|
+
return [p.slice(0, at), p.slice(at + 1)];
|
|
160
|
+
});
|
|
161
|
+
const timestamps = parts.filter(([k]) => k === "t").map(([, v]) => v);
|
|
162
|
+
const [t] = timestamps;
|
|
163
|
+
if (timestamps.length !== 1 || t === void 0 || !DIGITS.test(t)) {
|
|
164
|
+
return webhookError(
|
|
165
|
+
"malformed",
|
|
166
|
+
"the signature header needs exactly one timestamp, t=<unix seconds>"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const signatures = parts.filter(([k, v]) => k === "v1" && V1.test(v)).map(([, v]) => hexToBytes(v));
|
|
170
|
+
if (signatures.length === 0) {
|
|
171
|
+
return webhookError(
|
|
172
|
+
"malformed",
|
|
173
|
+
"the signature header has no v1 signature"
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return { timestamp: BigInt(t), signatures };
|
|
177
|
+
}
|
|
178
|
+
async function checkSignature(options) {
|
|
179
|
+
const parsed = parseSignatureHeader(options.header);
|
|
180
|
+
if ("code" in parsed) return parsed;
|
|
181
|
+
const now = BigInt(Math.floor(options.now.getTime() / 1e3));
|
|
182
|
+
const skew = now > parsed.timestamp ? now - parsed.timestamp : parsed.timestamp - now;
|
|
183
|
+
if (skew > BigInt(options.toleranceSeconds)) {
|
|
184
|
+
return webhookError(
|
|
185
|
+
"stale",
|
|
186
|
+
`the signature is ${skew} seconds from now; at most ${options.toleranceSeconds} are accepted`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const message = concat(utf8(`${parsed.timestamp}.`), options.body);
|
|
190
|
+
const subtle = await subtleCrypto();
|
|
191
|
+
for (const secret of options.secrets) {
|
|
192
|
+
const key = await hmacKey(secret, "verify");
|
|
193
|
+
for (const signature of parsed.signatures) {
|
|
194
|
+
if (await subtle.verify(
|
|
195
|
+
"HMAC",
|
|
196
|
+
key,
|
|
197
|
+
signature,
|
|
198
|
+
message
|
|
199
|
+
))
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return webhookError(
|
|
204
|
+
"mismatch",
|
|
205
|
+
"no signature matches the body with the signing secret: check the secret, and verify the raw body exactly as received"
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/webhooks/verify.ts
|
|
210
|
+
async function verifyWebhook(options) {
|
|
211
|
+
const secrets = signingSecrets(options.secret);
|
|
212
|
+
const toleranceSeconds = checkTolerance(options.toleranceSeconds);
|
|
213
|
+
const header = singleHeader(options.header);
|
|
214
|
+
if (header === void 0) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
error: webhookError("missing", "no Vinaverse-Signature header")
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (header === null) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
error: webhookError(
|
|
224
|
+
"malformed",
|
|
225
|
+
"more than one Vinaverse-Signature header"
|
|
226
|
+
)
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const body = toBytes(options.rawBody);
|
|
230
|
+
const refused = await checkSignature({
|
|
231
|
+
body,
|
|
232
|
+
header,
|
|
233
|
+
secrets,
|
|
234
|
+
now: options.now ?? /* @__PURE__ */ new Date(),
|
|
235
|
+
toleranceSeconds
|
|
236
|
+
});
|
|
237
|
+
if (refused) return { ok: false, error: refused };
|
|
238
|
+
let text;
|
|
239
|
+
try {
|
|
240
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(body);
|
|
241
|
+
} catch {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
error: webhookError("invalid_body", "the body is not UTF-8")
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const read = readBody(text);
|
|
248
|
+
return "code" in read ? { ok: false, error: read } : { ok: true, ...read };
|
|
249
|
+
}
|
|
250
|
+
function singleHeader(header) {
|
|
251
|
+
if (header === null || header === void 0) return void 0;
|
|
252
|
+
if (typeof header === "string") return header === "" ? void 0 : header;
|
|
253
|
+
if (header.length === 0) return void 0;
|
|
254
|
+
return header.length === 1 ? header[0] : null;
|
|
255
|
+
}
|
|
256
|
+
function signingSecrets(secret) {
|
|
257
|
+
const list = typeof secret === "string" ? [secret] : Array.isArray(secret) ? [...secret] : [];
|
|
258
|
+
if (list.length === 0 || list.some((s) => typeof s !== "string" || s === "")) {
|
|
259
|
+
throw new TypeError(
|
|
260
|
+
"the webhook signing secret is required (vv_whsec_\u2026), and none was given"
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return list;
|
|
264
|
+
}
|
|
265
|
+
function checkTolerance(seconds) {
|
|
266
|
+
if (seconds === void 0) return TOLERANCE_SECONDS;
|
|
267
|
+
if (!Number.isSafeInteger(seconds) || seconds < 0 || seconds > TOLERANCE_SECONDS) {
|
|
268
|
+
throw new TypeError(
|
|
269
|
+
`toleranceSeconds must be a whole number of seconds from 0 to ${TOLERANCE_SECONDS}, not ${seconds}`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return seconds;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export { SIGNATURE_HEADER, TOLERANCE_SECONDS, WEBHOOK_EVENT_TYPES, WebCryptoUnavailableError, bytesToHex, checkTolerance, concat, hexToBytes, hmacKey, isConversationEvent, parseSignatureHeader, sha256Hex, signingSecrets, subtleCrypto, toBytes, utf8, verifyWebhook, webCrypto };
|
|
276
|
+
//# sourceMappingURL=chunk-GTPMQKGX.js.map
|
|
277
|
+
//# sourceMappingURL=chunk-GTPMQKGX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/webhooks/crypto.ts","../src/internal/bytes.ts","../src/webhooks/errors.ts","../src/webhooks/events.ts","../src/webhooks/signature.ts","../src/webhooks/verify.ts"],"names":[],"mappings":"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","file":"chunk-GTPMQKGX.js","sourcesContent":["// Web Crypto wherever the package runs. Node 20+, AWS Lambda, edge runtimes\n// and React Native expose it as `globalThis.crypto`; Node 18 has the same API\n// but only as `webcrypto` in its crypto module, so on Node 18 it is loaded\n// from there, once.\n\n/** No Web Crypto in this runtime: the package cannot verify anything here. */\nexport class WebCryptoUnavailableError extends Error {\n constructor() {\n super(\n 'Web Crypto (crypto.subtle) is not available in this runtime; @vinaverse/server needs Node 18+, an edge runtime or another runtime with Web Crypto',\n );\n this.name = 'WebCryptoUnavailableError';\n }\n}\n\ntype MaybeNode = { process?: { versions?: { node?: unknown } } };\n\nlet fromNode: Promise<Crypto> | undefined;\n\n/** The runtime's Web Crypto: `crypto.subtle` and `crypto.getRandomValues`. */\nexport function webCrypto(): Promise<Crypto> {\n const global = (globalThis as { crypto?: Crypto }).crypto;\n if (global?.subtle) return Promise.resolve(global);\n fromNode ??= nodeWebCrypto();\n return fromNode;\n}\n\nexport async function subtleCrypto(): Promise<SubtleCrypto> {\n return (await webCrypto()).subtle;\n}\n\nasync function nodeWebCrypto(): Promise<Crypto> {\n if (typeof (globalThis as MaybeNode).process?.versions?.node !== 'string') {\n throw new WebCryptoUnavailableError();\n }\n // Built at run time on purpose: bundlers for edge runtimes and React Native,\n // where this line never runs, must not try to resolve a Node module.\n const specifier = ['node', 'crypto'].join(':');\n const mod = (await import(\n /* webpackIgnore: true */ /* @vite-ignore */ specifier\n )) as { webcrypto?: Crypto };\n if (!mod.webcrypto?.subtle) throw new WebCryptoUnavailableError();\n return mod.webcrypto;\n}\n","// Byte helpers on Web APIs only (no Buffer), shared by the signature checks.\nimport { subtleCrypto } from '../webhooks/crypto.js';\n\nconst encoder = new TextEncoder();\n\nexport function utf8(text: string): Uint8Array<ArrayBuffer> {\n return encoder.encode(text);\n}\n\nexport function toBytes(body: string | Uint8Array | ArrayBuffer): Uint8Array {\n if (typeof body === 'string') return utf8(body);\n if (body instanceof Uint8Array) return body;\n return new Uint8Array(body);\n}\n\nexport function hexToBytes(hex: string): Uint8Array<ArrayBuffer> {\n const bytes = new Uint8Array(hex.length / 2);\n for (let i = 0; i < bytes.length; i++) {\n bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);\n }\n return bytes;\n}\n\nexport function bytesToHex(bytes: Uint8Array): string {\n return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n}\n\nexport async function sha256Hex(data: Uint8Array): Promise<string> {\n const subtle = await subtleCrypto();\n return bytesToHex(new Uint8Array(await subtle.digest('SHA-256', copy(data))));\n}\n\nexport async function hmacKey(\n secret: string,\n usage: 'sign' | 'verify',\n): Promise<CryptoKey> {\n const subtle = await subtleCrypto();\n return subtle.importKey(\n 'raw',\n utf8(secret),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n [usage],\n );\n}\n\n// Web Crypto takes an ArrayBuffer-backed view; a Node Buffer may sit on a\n// shared pool, so a view is copied when it does not own its whole buffer.\nexport function copy(data: Uint8Array): Uint8Array<ArrayBuffer> {\n const out = new Uint8Array(data.length);\n out.set(data);\n return out;\n}\n\nexport function concat(...parts: Uint8Array[]): Uint8Array<ArrayBuffer> {\n const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));\n let at = 0;\n for (const p of parts) {\n out.set(p, at);\n at += p.length;\n }\n return out;\n}\n","/**\n * Why a delivery was refused, in the conformance vectors' words\n * (vinaverse-contracts `conformance/webhooks.json`). Every one is answered with\n * `400`, so Vinaverse retries it; none of them is ever a valid delivery.\n *\n * - `missing`: no `Vinaverse-Signature` header.\n * - `malformed`: the header is not `t=<unix seconds>,v1=<hex>[,v1=<hex>]`\n * (no single timestamp, or no `v1` signature).\n * - `stale`: signed more than the tolerance (300 seconds) before or after now:\n * a replay, or a clock far off.\n * - `mismatch`: no `v1` signature matches the body with any secret: a wrong\n * secret (or the secret without its `vv_whsec_` prefix), a changed body, or a\n * body parsed and serialized again.\n * - `invalid_body`: signed by Vinaverse, but not a webhook envelope this\n * package can read.\n */\nexport type WebhookErrorCode =\n | 'missing'\n | 'malformed'\n | 'stale'\n | 'mismatch'\n | 'invalid_body';\n\nexport interface WebhookError {\n readonly code: WebhookErrorCode;\n readonly message: string;\n}\n\nexport function webhookError(\n code: WebhookErrorCode,\n message: string,\n): WebhookError {\n return { code, message };\n}\n","// The webhook events, typed from the pinned contract (vinaverse-v1.yaml,\n// `webhooks`), and the reading of a verified body into one of them.\nimport type { components, webhooks } from '../generated/vinaverse-v1.js';\nimport { type WebhookError, webhookError } from './errors.js';\n\ntype Schemas = components['schemas'];\n\n/** The envelope every webhook body has. `data` depends on `type`. */\nexport type WebhookEnvelope = Schemas['WebhookEnvelope'];\n\n/** Every event type the targeted contract defines. */\nexport type WebhookEventType = WebhookEnvelope['type'] & keyof webhooks;\n\ntype BodyOf<T extends WebhookEventType> = NonNullable<\n webhooks[T]['post']['requestBody']\n>['content']['application/json'];\n\n/** One event of type `T`: the envelope, with `data` as the contract says. */\nexport type WebhookEventOf<T extends WebhookEventType> = Omit<\n WebhookEnvelope,\n 'type' | 'data'\n> & {\n type: T;\n data: NonNullable<BodyOf<T>['data']>;\n};\n\n/** Any event this package version knows; narrow it on `type`. */\nexport type WebhookEvent = {\n [T in WebhookEventType]: WebhookEventOf<T>;\n}[WebhookEventType];\n\nexport type ConversationEventType =\n | 'conversation.updated'\n | 'conversation.completed'\n | 'conversation.failed'\n | 'conversation.expired';\n\n/** A guided-flow conversation's state changed: `data` is its `ConversationState`. */\nexport type ConversationEvent = {\n [T in ConversationEventType]: WebhookEventOf<T>;\n}[ConversationEventType];\n\nexport type ConversationState = Schemas['ConversationState'];\n\n// Kept in step with the contract by a test that reads the pinned YAML.\nexport const WEBHOOK_EVENT_TYPES: readonly WebhookEventType[] = [\n 'moderation.item.completed',\n 'moderation.job.completed',\n 'document_check.completed',\n 'session.completed',\n 'subject.erased',\n 'usage.threshold_reached',\n 'feature.state_changed',\n 'media.job.queued',\n 'media.job.progress',\n 'media.job.step.completed',\n 'media.job.completed',\n 'media.job.failed',\n 'media.job.cancelled',\n 'conversation.updated',\n 'conversation.completed',\n 'conversation.failed',\n 'conversation.expired',\n];\n\nconst KNOWN = new Set<string>(WEBHOOK_EVENT_TYPES);\n\n// The statuses each conversation event may carry (vinaverse-v1.yaml, webhooks).\nconst CONVERSATION_STATUSES: Readonly<Record<string, readonly string[]>> = {\n 'conversation.updated': ['in_progress', 'failed'],\n 'conversation.completed': ['completed'],\n 'conversation.failed': ['failed'],\n 'conversation.expired': ['expired'],\n};\nconst ENVIRONMENTS = new Set(['mock', 'development', 'production']);\n\nexport function isConversationEvent(\n event: WebhookEvent,\n): event is ConversationEvent {\n return event.type.startsWith('conversation.');\n}\n\n/** What a verified body is: a known event, a test delivery, or a type newer than this package. */\nexport type ReadBody =\n | { kind: 'event'; event: WebhookEvent }\n | { kind: 'test'; envelope: WebhookEnvelope }\n | { kind: 'unrecognized'; envelope: WebhookEnvelope };\n\ntype Json = Record<string, unknown>;\n\nfunction isObject(value: unknown): value is Json {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/**\n * Reads a verified body: it must be the webhook envelope (`id`, `type`,\n * `created_at`, `environment`, `data`). A test delivery from Studio carries\n * `data: { test: true }` and no payload. A conversation event must carry a\n * guided flow's state with the status its type allows and the fields its\n * ordering depends on (`conversation_id`, `sequence`); other payloads are as\n * Vinaverse signed them. A type newer than this package is `unrecognized`,\n * never an error: answer it 2xx and upgrade.\n */\nexport function readBody(text: string): ReadBody | WebhookError {\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n return webhookError('invalid_body', 'the body is not JSON');\n }\n if (\n !isObject(parsed) ||\n typeof parsed.id !== 'string' ||\n typeof parsed.type !== 'string' ||\n typeof parsed.created_at !== 'string' ||\n typeof parsed.environment !== 'string' ||\n !ENVIRONMENTS.has(parsed.environment) ||\n !isObject(parsed.data)\n ) {\n return webhookError(\n 'invalid_body',\n 'the body is not a webhook envelope (id, type, created_at, environment, data)',\n );\n }\n const envelope = parsed as WebhookEnvelope;\n if (parsed.data.test === true) return { kind: 'test', envelope };\n if (!KNOWN.has(parsed.type)) return { kind: 'unrecognized', envelope };\n const statuses = CONVERSATION_STATUSES[parsed.type];\n if (statuses) {\n const { conversation_id, sequence, status, flow } = parsed.data;\n if (\n typeof conversation_id !== 'string' ||\n !Number.isSafeInteger(sequence) ||\n (sequence as number) < 1 ||\n typeof status !== 'string' ||\n !statuses.includes(status) ||\n typeof flow !== 'string'\n ) {\n return webhookError(\n 'invalid_body',\n `a ${parsed.type} event carries a guided flow's state: conversation_id, a positive integer sequence, a flow, and status ${statuses.join(' or ')}`,\n );\n }\n }\n return { kind: 'event', event: parsed as WebhookEvent };\n}\n","// The webhook signature, exactly as the Vinaverse API signs and checks it\n// (contract: docs/conversation-state.md §1, \"Delivery\"):\n// Vinaverse-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]\n// `v1` is the lowercase hex HMAC-SHA256, keyed with the endpoint's whole\n// signing secret as UTF-8 (the `vv_whsec_` prefix included, never decoded),\n// over `<t>.` followed by the raw request body. During a secret rotation the\n// header carries one `v1` per valid secret. A signature more than 300 seconds\n// from now, either way, is refused.\nimport { concat, hexToBytes, hmacKey, utf8 } from '../internal/bytes.js';\nimport { subtleCrypto } from './crypto.js';\nimport { type WebhookError, webhookError } from './errors.js';\n\nexport const SIGNATURE_HEADER = 'Vinaverse-Signature';\nexport const TOLERANCE_SECONDS = 300;\n\nconst DIGITS = /^[0-9]+$/;\nconst V1 = /^[0-9a-f]{64}$/;\n\nexport interface ParsedSignature {\n /** The signed timestamp, in Unix seconds. */\n readonly timestamp: bigint;\n /** Each `v1` signature, decoded. */\n readonly signatures: readonly Uint8Array[];\n}\n\n/**\n * Reads the header the way the API's own verifier does: comma-separated\n * `key=value` parts (a space around a part changes nothing), exactly one `t`\n * of digits, and the `v1` values. A `v1`\n * that is not 64 lowercase hex digits can never match, so it is left out; a\n * header left with no `v1` is malformed.\n */\nexport function parseSignatureHeader(\n header: string,\n): ParsedSignature | WebhookError {\n const parts = header\n .split(',')\n .map((p) => p.trim())\n .filter((p) => p.includes('='))\n .map((p) => {\n const at = p.indexOf('=');\n return [p.slice(0, at), p.slice(at + 1)] as const;\n });\n const timestamps = parts.filter(([k]) => k === 't').map(([, v]) => v);\n const [t] = timestamps;\n if (timestamps.length !== 1 || t === undefined || !DIGITS.test(t)) {\n return webhookError(\n 'malformed',\n 'the signature header needs exactly one timestamp, t=<unix seconds>',\n );\n }\n const signatures = parts\n .filter(([k, v]) => k === 'v1' && V1.test(v))\n .map(([, v]) => hexToBytes(v));\n if (signatures.length === 0) {\n return webhookError(\n 'malformed',\n 'the signature header has no v1 signature',\n );\n }\n return { timestamp: BigInt(t), signatures };\n}\n\n/**\n * Checks a delivery's signature: the header's form, its timestamp against\n * `now`, then each `v1` against each secret, in constant time (Web Crypto's\n * `verify`). `secrets` is more than one while the receiver rolls a new secret\n * out. Returns null when the signature is valid.\n */\nexport async function checkSignature(options: {\n body: Uint8Array;\n header: string;\n secrets: readonly string[];\n now: Date;\n toleranceSeconds: number;\n}): Promise<WebhookError | null> {\n const parsed = parseSignatureHeader(options.header);\n if ('code' in parsed) return parsed;\n const now = BigInt(Math.floor(options.now.getTime() / 1000));\n const skew =\n now > parsed.timestamp ? now - parsed.timestamp : parsed.timestamp - now;\n if (skew > BigInt(options.toleranceSeconds)) {\n return webhookError(\n 'stale',\n `the signature is ${skew} seconds from now; at most ${options.toleranceSeconds} are accepted`,\n );\n }\n const message = concat(utf8(`${parsed.timestamp}.`), options.body);\n const subtle = await subtleCrypto();\n for (const secret of options.secrets) {\n const key = await hmacKey(secret, 'verify');\n for (const signature of parsed.signatures) {\n // Every signature is decoded into its own ArrayBuffer (hexToBytes).\n if (\n await subtle.verify(\n 'HMAC',\n key,\n signature as Uint8Array<ArrayBuffer>,\n message,\n )\n )\n return null;\n }\n }\n return webhookError(\n 'mismatch',\n 'no signature matches the body with the signing secret: check the secret, and verify the raw body exactly as received',\n );\n}\n","import { toBytes } from '../internal/bytes.js';\nimport { type WebhookError, webhookError } from './errors.js';\nimport { type ReadBody, readBody } from './events.js';\nimport { checkSignature, TOLERANCE_SECONDS } from './signature.js';\n\nexport interface VerifyWebhookOptions {\n /**\n * The request body exactly as received, before any JSON parsing: a string,\n * or bytes (`Uint8Array`, which a Node `Buffer` is, or `ArrayBuffer`). A\n * body parsed and serialized again never verifies.\n */\n rawBody: string | Uint8Array | ArrayBuffer;\n /** The `Vinaverse-Signature` header (a framework's string array is accepted). */\n header: string | readonly string[] | null | undefined;\n /**\n * The endpoint's signing secret (`vv_whsec_…`), whole. Pass two while you\n * roll a new one out: a delivery verifies if either matches.\n */\n secret: string | readonly string[];\n /** The time to check the signature's timestamp against; default now. */\n now?: Date;\n /** How far from `now` a signature may be: whole seconds, 0 to 300 (the contract's window, the default). */\n toleranceSeconds?: number;\n}\n\nexport type VerifyWebhookResult =\n | ({ ok: true } & ReadBody)\n | { ok: false; error: WebhookError };\n\n/**\n * Verifies a Vinaverse webhook delivery and reads it, in one call:\n *\n * - `{ ok: true, kind: 'event', event }`: a verified event; narrow it on `event.type`.\n * - `{ ok: true, kind: 'test', envelope }`: a test delivery from Studio; answer 2xx, do nothing.\n * - `{ ok: true, kind: 'unrecognized', envelope }`: verified, but a type newer than this\n * package; answer 2xx, and upgrade to handle it.\n * - `{ ok: false, error }`: refused (`error.code` says why); answer 400.\n *\n * Deliveries are at least once: deduplicate on `event.id`, and order a\n * conversation's states with `sequenceVerdict` or `applyConversationState`.\n * It never throws for a bad delivery; it throws a `TypeError` for a missing\n * secret or a tolerance that is not a whole number of seconds from 0 to 300,\n * and `WebCryptoUnavailableError` in a runtime without Web Crypto.\n */\nexport async function verifyWebhook(\n options: VerifyWebhookOptions,\n): Promise<VerifyWebhookResult> {\n const secrets = signingSecrets(options.secret);\n const toleranceSeconds = checkTolerance(options.toleranceSeconds);\n const header = singleHeader(options.header);\n if (header === undefined) {\n return {\n ok: false,\n error: webhookError('missing', 'no Vinaverse-Signature header'),\n };\n }\n if (header === null) {\n return {\n ok: false,\n error: webhookError(\n 'malformed',\n 'more than one Vinaverse-Signature header',\n ),\n };\n }\n const body = toBytes(options.rawBody);\n const refused = await checkSignature({\n body,\n header,\n secrets,\n now: options.now ?? new Date(),\n toleranceSeconds,\n });\n if (refused) return { ok: false, error: refused };\n let text: string;\n try {\n text = new TextDecoder('utf-8', { fatal: true }).decode(body);\n } catch {\n return {\n ok: false,\n error: webhookError('invalid_body', 'the body is not UTF-8'),\n };\n }\n const read = readBody(text);\n return 'code' in read ? { ok: false, error: read } : { ok: true, ...read };\n}\n\n// undefined: absent; null: more than one; otherwise the header.\nfunction singleHeader(\n header: string | readonly string[] | null | undefined,\n): string | null | undefined {\n if (header === null || header === undefined) return undefined;\n if (typeof header === 'string') return header === '' ? undefined : header;\n if (header.length === 0) return undefined;\n return header.length === 1 ? header[0] : null;\n}\n\n/** The signing secrets, checked: a TypeError for none, an empty one, or an unset variable. */\nexport function signingSecrets(secret: unknown): string[] {\n const list =\n typeof secret === 'string'\n ? [secret]\n : Array.isArray(secret)\n ? [...secret]\n : [];\n if (\n list.length === 0 ||\n list.some((s) => typeof s !== 'string' || s === '')\n ) {\n throw new TypeError(\n 'the webhook signing secret is required (vv_whsec_…), and none was given',\n );\n }\n return list as string[];\n}\n\n/**\n * The tolerance, checked once: whole seconds from 0 to 300. A wider window is\n * a longer replay window than the contract allows.\n */\nexport function checkTolerance(seconds: number | undefined): number {\n if (seconds === undefined) return TOLERANCE_SECONDS;\n if (\n !Number.isSafeInteger(seconds) ||\n seconds < 0 ||\n seconds > TOLERANCE_SECONDS\n ) {\n throw new TypeError(\n `toleranceSeconds must be a whole number of seconds from 0 to ${TOLERANCE_SECONDS}, not ${seconds}`,\n );\n }\n return seconds;\n}\n"]}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { checkHandlerOptions, MAX_BODY_BYTES, tooLarge, handleWebhook } from './chunk-FEEJNQXV.js';
|
|
2
|
+
|
|
3
|
+
// src/adapters/fetch.ts
|
|
4
|
+
function createFetchWebhookHandler(options) {
|
|
5
|
+
checkHandlerOptions(options);
|
|
6
|
+
const max = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
7
|
+
return async (request) => {
|
|
8
|
+
const body = await readCapped(request, max);
|
|
9
|
+
if (body === void 0) return toResponse(tooLarge());
|
|
10
|
+
return toResponse(
|
|
11
|
+
await handleWebhook(
|
|
12
|
+
options,
|
|
13
|
+
body,
|
|
14
|
+
request.headers.get("vinaverse-signature")
|
|
15
|
+
)
|
|
16
|
+
);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async function readCapped(request, max) {
|
|
20
|
+
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
21
|
+
if (declared > max) return void 0;
|
|
22
|
+
if (!request.body) return new Uint8Array(0);
|
|
23
|
+
const reader = request.body.getReader();
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let size = 0;
|
|
26
|
+
for (; ; ) {
|
|
27
|
+
const { done, value } = await reader.read();
|
|
28
|
+
if (done) break;
|
|
29
|
+
size += value.length;
|
|
30
|
+
if (size > max) {
|
|
31
|
+
await reader.cancel();
|
|
32
|
+
return void 0;
|
|
33
|
+
}
|
|
34
|
+
chunks.push(value);
|
|
35
|
+
}
|
|
36
|
+
const body = new Uint8Array(size);
|
|
37
|
+
let at = 0;
|
|
38
|
+
for (const chunk of chunks) {
|
|
39
|
+
body.set(chunk, at);
|
|
40
|
+
at += chunk.length;
|
|
41
|
+
}
|
|
42
|
+
return body;
|
|
43
|
+
}
|
|
44
|
+
function toResponse(response) {
|
|
45
|
+
return new Response(response.body, {
|
|
46
|
+
status: response.status,
|
|
47
|
+
headers: { "content-type": "application/json" }
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function createFetchToolHandler(provider, options = {}) {
|
|
51
|
+
const max = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
52
|
+
return async (request) => {
|
|
53
|
+
const url = new URL(request.url);
|
|
54
|
+
const reads = request.method !== "GET" && request.method !== "HEAD";
|
|
55
|
+
const body = reads ? await readCapped(request, max) : null;
|
|
56
|
+
if (body === void 0) {
|
|
57
|
+
return new Response(
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
status: 413,
|
|
60
|
+
code: "invalid_argument",
|
|
61
|
+
detail: "the request body is too large"
|
|
62
|
+
}),
|
|
63
|
+
{
|
|
64
|
+
status: 413,
|
|
65
|
+
headers: { "content-type": "application/problem+json" }
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const response = await provider.handle({
|
|
70
|
+
method: request.method,
|
|
71
|
+
target: `${url.pathname}${url.search}`,
|
|
72
|
+
header: (name) => request.headers.get(name) ?? void 0,
|
|
73
|
+
body
|
|
74
|
+
});
|
|
75
|
+
return new Response(response.body, {
|
|
76
|
+
status: response.status,
|
|
77
|
+
headers: response.headers
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { createFetchToolHandler, createFetchWebhookHandler };
|
|
83
|
+
//# sourceMappingURL=chunk-NPXO6SMZ.js.map
|
|
84
|
+
//# sourceMappingURL=chunk-NPXO6SMZ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/adapters/fetch.ts"],"names":[],"mappings":"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","file":"chunk-NPXO6SMZ.js","sourcesContent":["// Web-standard handlers: anything that hands over a `Request` and takes a\n// `Response` (Hono, Next.js route handlers, Cloudflare Workers, Deno, Bun).\nimport {\n checkHandlerOptions,\n handleWebhook,\n MAX_BODY_BYTES,\n tooLarge,\n type WebhookHandlerOptions,\n type WebhookResponse,\n} from './handler.js';\n\nexport type { WebhookHandlerOptions } from './handler.js';\n\nimport type { ToolProvider } from '../tools/provider.js';\n\n/**\n * `(request) => Promise<Response>` for a Vinaverse webhook endpoint. It reads\n * the raw body itself, so mount it where nothing has parsed the body:\n *\n * export const POST = createFetchWebhookHandler({ secret, onEvent });\n */\nexport function createFetchWebhookHandler(\n options: WebhookHandlerOptions,\n): (request: Request) => Promise<Response> {\n checkHandlerOptions(options);\n const max = options.maxBodyBytes ?? MAX_BODY_BYTES;\n return async (request) => {\n const body = await readCapped(request, max);\n if (body === undefined) return toResponse(tooLarge());\n return toResponse(\n await handleWebhook(\n options,\n body,\n request.headers.get('vinaverse-signature'),\n ),\n );\n };\n}\n\n/**\n * The body, read as a stream and stopped past `max` bytes (undefined then),\n * so neither a declared nor a chunked oversized body is held in memory.\n */\nexport async function readCapped(\n request: Request,\n max: number,\n): Promise<Uint8Array | undefined> {\n const declared = Number(request.headers.get('content-length') ?? '0');\n if (declared > max) return undefined;\n if (!request.body) return new Uint8Array(0);\n const reader = request.body.getReader();\n const chunks: Uint8Array[] = [];\n let size = 0;\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n size += value.length;\n if (size > max) {\n await reader.cancel();\n return undefined;\n }\n chunks.push(value);\n }\n const body = new Uint8Array(size);\n let at = 0;\n for (const chunk of chunks) {\n body.set(chunk, at);\n at += chunk.length;\n }\n return body;\n}\n\nexport function toResponse(response: WebhookResponse): Response {\n return new Response(response.body, {\n status: response.status,\n headers: { 'content-type': 'application/json' },\n });\n}\n\n/**\n * `(request) => Promise<Response>` serving a tenant's tools: every tool route\n * under the provider's `basePath`, with the contract enforced. The target is\n * the request URL's path and query as received.\n *\n * const handle = createFetchToolHandler(createToolProvider({ … }));\n */\nexport function createFetchToolHandler(\n provider: ToolProvider,\n options: { maxBodyBytes?: number } = {},\n): (request: Request) => Promise<Response> {\n const max = options.maxBodyBytes ?? MAX_BODY_BYTES;\n return async (request) => {\n const url = new URL(request.url);\n const reads = request.method !== 'GET' && request.method !== 'HEAD';\n const body = reads ? await readCapped(request, max) : null;\n if (body === undefined) {\n return new Response(\n JSON.stringify({\n status: 413,\n code: 'invalid_argument',\n detail: 'the request body is too large',\n }),\n {\n status: 413,\n headers: { 'content-type': 'application/problem+json' },\n },\n );\n }\n const response = await provider.handle({\n method: request.method,\n target: `${url.pathname}${url.search}`,\n header: (name) => request.headers.get(name) ?? undefined,\n body,\n });\n return new Response(response.body, {\n status: response.status,\n headers: response.headers,\n });\n };\n}\n"]}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { sha256Hex, utf8, checkTolerance, subtleCrypto, hmacKey, hexToBytes } from './chunk-GTPMQKGX.js';
|
|
2
|
+
|
|
3
|
+
// src/tools/nonces.ts
|
|
4
|
+
var NONCE_KEEP_SECONDS = 360;
|
|
5
|
+
var MemoryNonceStore = class {
|
|
6
|
+
#seen = /* @__PURE__ */ new Map();
|
|
7
|
+
#now;
|
|
8
|
+
#sweepEvery;
|
|
9
|
+
#claims = 0;
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.#now = options.now ?? (() => Math.floor(Date.now() / 1e3));
|
|
12
|
+
this.#sweepEvery = Math.max(1, options.sweepEvery ?? 256);
|
|
13
|
+
}
|
|
14
|
+
async claim(nonce, keepUntil) {
|
|
15
|
+
const now = this.#now();
|
|
16
|
+
if (++this.#claims % this.#sweepEvery === 0) {
|
|
17
|
+
for (const [n, until2] of this.#seen) {
|
|
18
|
+
if (until2 < now) this.#seen.delete(n);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const until = this.#seen.get(nonce);
|
|
22
|
+
if (until !== void 0 && until >= now) return false;
|
|
23
|
+
this.#seen.set(nonce, keepUntil);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
/** Nonces held, expired ones included until a sweep drops them. */
|
|
27
|
+
get size() {
|
|
28
|
+
return this.#seen.size;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// src/tools/signature.ts
|
|
33
|
+
var TOOL_TOLERANCE_SECONDS = 300;
|
|
34
|
+
var T = /^[0-9]{1,12}$/;
|
|
35
|
+
var NONCE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
36
|
+
var MAC = /^[0-9a-f]{64}$/;
|
|
37
|
+
function parseToolSignature(header) {
|
|
38
|
+
let t;
|
|
39
|
+
let n;
|
|
40
|
+
let seenT = 0;
|
|
41
|
+
let seenN = 0;
|
|
42
|
+
const v2 = [];
|
|
43
|
+
for (const part of header.split(",")) {
|
|
44
|
+
const trimmed = part.trim();
|
|
45
|
+
const at = trimmed.indexOf("=");
|
|
46
|
+
const key = at < 0 ? trimmed : trimmed.slice(0, at);
|
|
47
|
+
const value = at < 0 ? "" : trimmed.slice(at + 1);
|
|
48
|
+
if (key === "t") {
|
|
49
|
+
seenT++;
|
|
50
|
+
if (T.test(value)) t = Number(value);
|
|
51
|
+
} else if (key === "n") {
|
|
52
|
+
seenN++;
|
|
53
|
+
n = value;
|
|
54
|
+
} else if (key === "v2" && MAC.test(value)) {
|
|
55
|
+
v2.push(value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { t, n, v2, repeated: seenT > 1 || seenN > 1 };
|
|
59
|
+
}
|
|
60
|
+
async function toolSigningString(call) {
|
|
61
|
+
const bodyHash = call.body && call.body.length > 0 ? await sha256Hex(call.body) : "";
|
|
62
|
+
return utf8(
|
|
63
|
+
[
|
|
64
|
+
"v2",
|
|
65
|
+
String(call.t),
|
|
66
|
+
call.n,
|
|
67
|
+
call.method.toUpperCase(),
|
|
68
|
+
call.target,
|
|
69
|
+
await sha256Hex(utf8(call.token)),
|
|
70
|
+
bodyHash
|
|
71
|
+
].join("\n")
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
async function verifyToolCall(call, options) {
|
|
75
|
+
if (!call.header) {
|
|
76
|
+
return refusal("missing", "no Vinaverse-Signature header");
|
|
77
|
+
}
|
|
78
|
+
const { t, n, v2, repeated } = parseToolSignature(call.header);
|
|
79
|
+
if (repeated) {
|
|
80
|
+
return refusal(
|
|
81
|
+
"malformed",
|
|
82
|
+
"the signature repeats t or n: an ambiguous header"
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (t === void 0) {
|
|
86
|
+
return refusal(
|
|
87
|
+
"malformed",
|
|
88
|
+
"the signature has no timestamp t=<unix seconds>"
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const now = Math.floor(options.now.getTime() / 1e3);
|
|
92
|
+
const tolerance = options.toleranceSeconds === void 0 ? TOOL_TOLERANCE_SECONDS : checkTolerance(options.toleranceSeconds);
|
|
93
|
+
if (Math.abs(now - t) > tolerance) {
|
|
94
|
+
return refusal(
|
|
95
|
+
"stale",
|
|
96
|
+
`the signature is ${Math.abs(now - t)} seconds from now; at most ${tolerance} are accepted`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (v2.length === 0) {
|
|
100
|
+
return refusal(
|
|
101
|
+
"v2_required",
|
|
102
|
+
"the signature has no v2 (a v1-only header binds neither the token nor a nonce)"
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (n === void 0 || !NONCE.test(n)) {
|
|
106
|
+
return refusal(
|
|
107
|
+
"malformed",
|
|
108
|
+
"the signature has no valid nonce n (16 to 64 of A-Z a-z 0-9 - _)"
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const message = await toolSigningString({ ...call, t, n });
|
|
112
|
+
const subtle = await subtleCrypto();
|
|
113
|
+
let matched = false;
|
|
114
|
+
for (const secret of options.secrets) {
|
|
115
|
+
const key = await hmacKey(secret, "verify");
|
|
116
|
+
for (const mac of v2) {
|
|
117
|
+
if (await subtle.verify("HMAC", key, hexToBytes(mac), message)) {
|
|
118
|
+
matched = true;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (matched) break;
|
|
123
|
+
}
|
|
124
|
+
if (!matched) {
|
|
125
|
+
return refusal(
|
|
126
|
+
"mismatch",
|
|
127
|
+
"no v2 signature matches: check the tool signing secret, and verify the method, target, token and body exactly as received"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (!await options.nonces.claim(n, Math.max(t, now) + NONCE_KEEP_SECONDS)) {
|
|
131
|
+
return refusal("replayed", "this nonce was already used: a replayed call");
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
function refusal(code, message) {
|
|
136
|
+
return { code, message };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { MemoryNonceStore, NONCE_KEEP_SECONDS, toolSigningString, verifyToolCall };
|
|
140
|
+
//# sourceMappingURL=chunk-SDJFZPHJ.js.map
|
|
141
|
+
//# sourceMappingURL=chunk-SDJFZPHJ.js.map
|