@vinaverse/server 0.0.0-stage → 0.1.0-alpha.10
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 +169 -2
- package/dist/api.cjs +546 -0
- package/dist/api.cjs.map +1 -0
- package/dist/api.d.cts +473 -0
- package/dist/api.d.ts +473 -0
- package/dist/api.js +534 -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 +1509 -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 +948 -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-CbLgJEXQ.d.ts +2193 -0
- package/dist/provider-ytczljc-.d.cts +2193 -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-DT7a1T80.d.cts +3867 -0
- package/dist/vinaverse-v1.d-DT7a1T80.d.ts +3867 -0
- package/package.json +225 -3
package/dist/stores.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// src/stores/dynamodb.ts
|
|
2
|
+
var ABSENT_OR_EXPIRED = "attribute_not_exists(pk) OR expires_at < :now";
|
|
3
|
+
function conditionFailed(error) {
|
|
4
|
+
return error?.name === "ConditionalCheckFailedException";
|
|
5
|
+
}
|
|
6
|
+
function dynamoNonceStore(options) {
|
|
7
|
+
const now = options.now ?? (() => Math.floor(Date.now() / 1e3));
|
|
8
|
+
return {
|
|
9
|
+
async claim(nonce, keepUntil) {
|
|
10
|
+
try {
|
|
11
|
+
await options.send("PutItem", {
|
|
12
|
+
TableName: options.table,
|
|
13
|
+
Item: {
|
|
14
|
+
pk: { S: `${options.prefix ?? ""}nonce#${nonce}` },
|
|
15
|
+
expires_at: { N: String(Math.ceil(keepUntil)) }
|
|
16
|
+
},
|
|
17
|
+
ConditionExpression: ABSENT_OR_EXPIRED,
|
|
18
|
+
ExpressionAttributeValues: { ":now": { N: String(now()) } }
|
|
19
|
+
});
|
|
20
|
+
return true;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (conditionFailed(error)) return false;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
var FREE_OR_OWNED = `${ABSENT_OR_EXPIRED} OR (#state = :pending AND #owner = :owner)`;
|
|
29
|
+
function dynamoIdempotencyStore(options) {
|
|
30
|
+
const now = options.now ?? (() => Math.floor(Date.now() / 1e3));
|
|
31
|
+
const pk = (key) => ({
|
|
32
|
+
S: `${options.prefix ?? ""}idempotency#${key}`
|
|
33
|
+
});
|
|
34
|
+
const owned = (owner) => ({
|
|
35
|
+
ExpressionAttributeNames: { "#state": "state", "#owner": "owner" },
|
|
36
|
+
":pending": { S: "pending" },
|
|
37
|
+
":owner": { S: owner }
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
async start(key, fingerprint, pendingSeconds, owner) {
|
|
41
|
+
for (; ; ) {
|
|
42
|
+
try {
|
|
43
|
+
await options.send("PutItem", {
|
|
44
|
+
TableName: options.table,
|
|
45
|
+
Item: {
|
|
46
|
+
pk: pk(key),
|
|
47
|
+
fingerprint: { S: fingerprint },
|
|
48
|
+
state: { S: "pending" },
|
|
49
|
+
owner: { S: owner },
|
|
50
|
+
expires_at: { N: String(now() + Math.ceil(pendingSeconds)) }
|
|
51
|
+
},
|
|
52
|
+
ConditionExpression: ABSENT_OR_EXPIRED,
|
|
53
|
+
ExpressionAttributeValues: { ":now": { N: String(now()) } }
|
|
54
|
+
});
|
|
55
|
+
return null;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!conditionFailed(error)) throw error;
|
|
58
|
+
}
|
|
59
|
+
const held = await options.send("GetItem", {
|
|
60
|
+
TableName: options.table,
|
|
61
|
+
Key: { pk: pk(key) },
|
|
62
|
+
ConsistentRead: true
|
|
63
|
+
});
|
|
64
|
+
const item = held?.Item;
|
|
65
|
+
if (item && Number(item.expires_at?.N) >= now()) {
|
|
66
|
+
return {
|
|
67
|
+
fingerprint: item.fingerprint?.S ?? "",
|
|
68
|
+
state: item.state?.S === "done" ? "done" : "pending",
|
|
69
|
+
...item.status?.N === void 0 ? {} : { status: Number(item.status.N) },
|
|
70
|
+
...item.body?.S === void 0 ? {} : { body: item.body.S }
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
async finish(key, record, keepSeconds, owner) {
|
|
76
|
+
const { ExpressionAttributeNames, ...values } = owned(owner);
|
|
77
|
+
try {
|
|
78
|
+
await options.send("PutItem", {
|
|
79
|
+
TableName: options.table,
|
|
80
|
+
Item: {
|
|
81
|
+
pk: pk(key),
|
|
82
|
+
fingerprint: { S: record.fingerprint },
|
|
83
|
+
state: { S: "done" },
|
|
84
|
+
status: { N: String(record.status) },
|
|
85
|
+
body: { S: record.body },
|
|
86
|
+
expires_at: { N: String(now() + Math.ceil(keepSeconds)) }
|
|
87
|
+
},
|
|
88
|
+
ConditionExpression: FREE_OR_OWNED,
|
|
89
|
+
ExpressionAttributeNames,
|
|
90
|
+
ExpressionAttributeValues: {
|
|
91
|
+
...values,
|
|
92
|
+
":now": { N: String(now()) }
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
return true;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (conditionFailed(error)) return false;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
async release(key, owner) {
|
|
102
|
+
const { ExpressionAttributeNames, ...values } = owned(owner);
|
|
103
|
+
try {
|
|
104
|
+
await options.send("DeleteItem", {
|
|
105
|
+
TableName: options.table,
|
|
106
|
+
Key: { pk: pk(key) },
|
|
107
|
+
ConditionExpression: "#state = :pending AND #owner = :owner",
|
|
108
|
+
ExpressionAttributeNames,
|
|
109
|
+
ExpressionAttributeValues: values
|
|
110
|
+
});
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!conditionFailed(error)) throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/stores/postgres.ts
|
|
119
|
+
var PREFIX = /^[a-z_][a-z0-9_]{0,40}$/;
|
|
120
|
+
function postgresTables(tablePrefix = "vinaverse_") {
|
|
121
|
+
const t = tables(tablePrefix);
|
|
122
|
+
return `
|
|
123
|
+
CREATE TABLE IF NOT EXISTS ${t.nonces} (
|
|
124
|
+
nonce text PRIMARY KEY,
|
|
125
|
+
keep_until timestamptz NOT NULL
|
|
126
|
+
);
|
|
127
|
+
CREATE TABLE IF NOT EXISTS ${t.idempotency} (
|
|
128
|
+
key text PRIMARY KEY,
|
|
129
|
+
fingerprint text NOT NULL,
|
|
130
|
+
state text NOT NULL CHECK (state IN ('pending', 'done')),
|
|
131
|
+
owner text,
|
|
132
|
+
status integer,
|
|
133
|
+
body text,
|
|
134
|
+
keep_until timestamptz NOT NULL
|
|
135
|
+
);`;
|
|
136
|
+
}
|
|
137
|
+
var POSTGRES_TABLES = postgresTables();
|
|
138
|
+
function tables(prefix) {
|
|
139
|
+
if (!PREFIX.test(prefix)) {
|
|
140
|
+
throw new TypeError(
|
|
141
|
+
`tablePrefix must be lower-case letters, digits and _, starting with a letter or _, not ${JSON.stringify(prefix)}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return { nonces: `${prefix}nonces`, idempotency: `${prefix}idempotency` };
|
|
145
|
+
}
|
|
146
|
+
function pruner(options, sql) {
|
|
147
|
+
const every = options.pruneEvery ?? 1e3;
|
|
148
|
+
let count = 0;
|
|
149
|
+
return async () => {
|
|
150
|
+
count = (count + 1) % every;
|
|
151
|
+
if (count === 0) await options.query(sql, []);
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function postgresNonceStore(options) {
|
|
155
|
+
const { nonces } = tables(options.tablePrefix ?? "vinaverse_");
|
|
156
|
+
const claim = `INSERT INTO ${nonces} (nonce, keep_until) VALUES ($1, to_timestamp($2))
|
|
157
|
+
ON CONFLICT (nonce) DO UPDATE SET keep_until = EXCLUDED.keep_until
|
|
158
|
+
WHERE ${nonces}.keep_until < now()
|
|
159
|
+
RETURNING nonce`;
|
|
160
|
+
const prune = pruner(
|
|
161
|
+
options,
|
|
162
|
+
`DELETE FROM ${nonces} WHERE keep_until < now()`
|
|
163
|
+
);
|
|
164
|
+
return {
|
|
165
|
+
async claim(nonce, keepUntil) {
|
|
166
|
+
await prune();
|
|
167
|
+
const { rows } = await options.query(claim, [nonce, keepUntil]);
|
|
168
|
+
return rows.length === 1;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function postgresIdempotencyStore(options) {
|
|
173
|
+
const { idempotency: t } = tables(options.tablePrefix ?? "vinaverse_");
|
|
174
|
+
const start = `INSERT INTO ${t} (key, fingerprint, state, owner, keep_until)
|
|
175
|
+
VALUES ($1, $2, 'pending', $4, now() + make_interval(secs => $3))
|
|
176
|
+
ON CONFLICT (key) DO UPDATE SET fingerprint = EXCLUDED.fingerprint, state = 'pending', owner = EXCLUDED.owner,
|
|
177
|
+
status = NULL, body = NULL, keep_until = EXCLUDED.keep_until
|
|
178
|
+
WHERE ${t}.keep_until < now()
|
|
179
|
+
RETURNING key`;
|
|
180
|
+
const held = `SELECT fingerprint, state, status, body FROM ${t}
|
|
181
|
+
WHERE key = $1 AND keep_until >= now()`;
|
|
182
|
+
const finish = `INSERT INTO ${t} (key, fingerprint, state, owner, status, body, keep_until)
|
|
183
|
+
VALUES ($1, $2, 'done', NULL, $3, $4, now() + make_interval(secs => $5))
|
|
184
|
+
ON CONFLICT (key) DO UPDATE SET fingerprint = EXCLUDED.fingerprint, state = 'done', owner = NULL,
|
|
185
|
+
status = EXCLUDED.status, body = EXCLUDED.body, keep_until = EXCLUDED.keep_until
|
|
186
|
+
WHERE ${t}.keep_until < now() OR (${t}.state = 'pending' AND ${t}.owner = $6)
|
|
187
|
+
RETURNING key`;
|
|
188
|
+
const release = `DELETE FROM ${t} WHERE key = $1 AND state = 'pending' AND owner = $2`;
|
|
189
|
+
const prune = pruner(options, `DELETE FROM ${t} WHERE keep_until < now()`);
|
|
190
|
+
return {
|
|
191
|
+
async start(key, fingerprint, pendingSeconds, owner) {
|
|
192
|
+
await prune();
|
|
193
|
+
for (; ; ) {
|
|
194
|
+
const started = await options.query(start, [
|
|
195
|
+
key,
|
|
196
|
+
fingerprint,
|
|
197
|
+
pendingSeconds,
|
|
198
|
+
owner
|
|
199
|
+
]);
|
|
200
|
+
if (started.rows.length === 1) return null;
|
|
201
|
+
const [row] = (await options.query(held, [key])).rows;
|
|
202
|
+
if (row) {
|
|
203
|
+
return {
|
|
204
|
+
fingerprint: String(row.fingerprint),
|
|
205
|
+
state: row.state === "done" ? "done" : "pending",
|
|
206
|
+
...row.status === null || row.status === void 0 ? {} : { status: Number(row.status) },
|
|
207
|
+
...row.body === null || row.body === void 0 ? {} : { body: String(row.body) }
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
async finish(key, record, keepSeconds, owner) {
|
|
213
|
+
const { rows } = await options.query(finish, [
|
|
214
|
+
key,
|
|
215
|
+
record.fingerprint,
|
|
216
|
+
record.status,
|
|
217
|
+
record.body,
|
|
218
|
+
keepSeconds,
|
|
219
|
+
owner
|
|
220
|
+
]);
|
|
221
|
+
return rows.length === 1;
|
|
222
|
+
},
|
|
223
|
+
async release(key, owner) {
|
|
224
|
+
await options.query(release, [key, owner]);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/stores/redis.ts
|
|
230
|
+
function redisNonceStore(options) {
|
|
231
|
+
const prefix = options.prefix ?? "vinaverse:";
|
|
232
|
+
return {
|
|
233
|
+
async claim(nonce, keepUntil) {
|
|
234
|
+
const reply = await options.command([
|
|
235
|
+
"SET",
|
|
236
|
+
`${prefix}nonce:${nonce}`,
|
|
237
|
+
"1",
|
|
238
|
+
"NX",
|
|
239
|
+
"EXAT",
|
|
240
|
+
String(Math.ceil(keepUntil))
|
|
241
|
+
]);
|
|
242
|
+
return reply === "OK";
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
var RELEASE_IF_OWNED = `local held = redis.call('GET', KEYS[1])
|
|
247
|
+
if held then
|
|
248
|
+
local r = cjson.decode(held)
|
|
249
|
+
if r.state == 'pending' and r.owner == ARGV[1] then return redis.call('DEL', KEYS[1]) end
|
|
250
|
+
end
|
|
251
|
+
return 0`;
|
|
252
|
+
var FINISH_IF_OWNED = `local held = redis.call('GET', KEYS[1])
|
|
253
|
+
if held then
|
|
254
|
+
local r = cjson.decode(held)
|
|
255
|
+
if r.state ~= 'pending' or r.owner ~= ARGV[1] then return 0 end
|
|
256
|
+
end
|
|
257
|
+
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
|
|
258
|
+
return 1`;
|
|
259
|
+
function redisIdempotencyStore(options) {
|
|
260
|
+
const prefix = options.prefix ?? "vinaverse:";
|
|
261
|
+
const key = (k) => `${prefix}idempotency:${k}`;
|
|
262
|
+
return {
|
|
263
|
+
async start(k, fingerprint, pendingSeconds, owner) {
|
|
264
|
+
for (; ; ) {
|
|
265
|
+
const set = await options.command([
|
|
266
|
+
"SET",
|
|
267
|
+
key(k),
|
|
268
|
+
JSON.stringify({ fingerprint, state: "pending", owner }),
|
|
269
|
+
"NX",
|
|
270
|
+
"EX",
|
|
271
|
+
seconds(pendingSeconds)
|
|
272
|
+
]);
|
|
273
|
+
if (set === "OK") return null;
|
|
274
|
+
const held = await options.command(["GET", key(k)]);
|
|
275
|
+
if (typeof held === "string") {
|
|
276
|
+
const { owner: _, ...record } = JSON.parse(
|
|
277
|
+
held
|
|
278
|
+
);
|
|
279
|
+
return record;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
async finish(k, record, keepSeconds, owner) {
|
|
284
|
+
const stored = await options.command([
|
|
285
|
+
"EVAL",
|
|
286
|
+
FINISH_IF_OWNED,
|
|
287
|
+
"1",
|
|
288
|
+
key(k),
|
|
289
|
+
owner,
|
|
290
|
+
JSON.stringify({ ...record, state: "done" }),
|
|
291
|
+
seconds(keepSeconds)
|
|
292
|
+
]);
|
|
293
|
+
return Number(stored) === 1;
|
|
294
|
+
},
|
|
295
|
+
async release(k, owner) {
|
|
296
|
+
await options.command(["EVAL", RELEASE_IF_OWNED, "1", key(k), owner]);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function seconds(value) {
|
|
301
|
+
return String(Math.max(1, Math.ceil(value)));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export { POSTGRES_TABLES, dynamoIdempotencyStore, dynamoNonceStore, postgresIdempotencyStore, postgresNonceStore, postgresTables, redisIdempotencyStore, redisNonceStore };
|
|
305
|
+
//# sourceMappingURL=stores.js.map
|
|
306
|
+
//# sourceMappingURL=stores.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/stores/dynamodb.ts","../src/stores/postgres.ts","../src/stores/redis.ts"],"names":[],"mappings":"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","file":"stores.js","sourcesContent":["// Nonces and idempotent answers in DynamoDB, shared by every Lambda instance.\n// One table with a string partition key `pk` and DynamoDB TTL on the number\n// attribute `expires_at` (DynamoDB deletes expired items lazily, so every\n// condition here also checks the time). Works with the AWS SDK v3 through one\n// function that sends a command by name:\n// const commands = { PutItem: PutItemCommand, GetItem: GetItemCommand, DeleteItem: DeleteItemCommand };\n// dynamoNonceStore({ table, send: (name, input) => client.send(new commands[name](input)) })\nimport type {\n IdempotencyRecord,\n IdempotencyStore,\n} from '../tools/idempotency.js';\nimport type { NonceStore } from '../tools/nonces.js';\n\nexport type DynamoCommand = 'PutItem' | 'GetItem' | 'DeleteItem';\n\nexport type DynamoSend = (\n command: DynamoCommand,\n input: Record<string, unknown>,\n) => Promise<{ Item?: Record<string, { S?: string; N?: string }> } | undefined>;\n\nexport interface DynamoStoreOptions {\n table: string;\n send: DynamoSend;\n /** The clock, in Unix seconds; default the system clock. */\n now?: () => number;\n /** Prefix for every partition key, default none: one per environment or signing secret sharing a table. */\n prefix?: string;\n}\n\nconst ABSENT_OR_EXPIRED = 'attribute_not_exists(pk) OR expires_at < :now';\n\nfunction conditionFailed(error: unknown): boolean {\n return (\n (error as { name?: string } | null)?.name ===\n 'ConditionalCheckFailedException'\n );\n}\n\nexport function dynamoNonceStore(options: DynamoStoreOptions): NonceStore {\n const now = options.now ?? (() => Math.floor(Date.now() / 1000));\n return {\n async claim(nonce, keepUntil) {\n try {\n await options.send('PutItem', {\n TableName: options.table,\n Item: {\n pk: { S: `${options.prefix ?? ''}nonce#${nonce}` },\n expires_at: { N: String(Math.ceil(keepUntil)) },\n },\n ConditionExpression: ABSENT_OR_EXPIRED,\n ExpressionAttributeValues: { ':now': { N: String(now()) } },\n });\n return true;\n } catch (error) {\n if (conditionFailed(error)) return false;\n throw error;\n }\n },\n };\n}\n\n// An answer is stored only when the key is free or still this call's pending lease.\nconst FREE_OR_OWNED = `${ABSENT_OR_EXPIRED} OR (#state = :pending AND #owner = :owner)`;\n\nexport function dynamoIdempotencyStore(\n options: DynamoStoreOptions,\n): IdempotencyStore {\n const now = options.now ?? (() => Math.floor(Date.now() / 1000));\n const pk = (key: string) => ({\n S: `${options.prefix ?? ''}idempotency#${key}`,\n });\n const owned = (owner: string) => ({\n ExpressionAttributeNames: { '#state': 'state', '#owner': 'owner' },\n ':pending': { S: 'pending' },\n ':owner': { S: owner },\n });\n return {\n async start(key, fingerprint, pendingSeconds, owner) {\n for (;;) {\n try {\n await options.send('PutItem', {\n TableName: options.table,\n Item: {\n pk: pk(key),\n fingerprint: { S: fingerprint },\n state: { S: 'pending' },\n owner: { S: owner },\n expires_at: { N: String(now() + Math.ceil(pendingSeconds)) },\n },\n ConditionExpression: ABSENT_OR_EXPIRED,\n ExpressionAttributeValues: { ':now': { N: String(now()) } },\n });\n return null;\n } catch (error) {\n if (!conditionFailed(error)) throw error;\n }\n const held = await options.send('GetItem', {\n TableName: options.table,\n Key: { pk: pk(key) },\n ConsistentRead: true,\n });\n const item = held?.Item;\n // Lapsed between the two calls: try to take it again.\n if (item && Number(item.expires_at?.N) >= now()) {\n return {\n fingerprint: item.fingerprint?.S ?? '',\n state: item.state?.S === 'done' ? 'done' : 'pending',\n ...(item.status?.N === undefined\n ? {}\n : { status: Number(item.status.N) }),\n ...(item.body?.S === undefined ? {} : { body: item.body.S }),\n } satisfies IdempotencyRecord;\n }\n }\n },\n async finish(key, record, keepSeconds, owner) {\n const { ExpressionAttributeNames, ...values } = owned(owner);\n try {\n await options.send('PutItem', {\n TableName: options.table,\n Item: {\n pk: pk(key),\n fingerprint: { S: record.fingerprint },\n state: { S: 'done' },\n status: { N: String(record.status) },\n body: { S: record.body },\n expires_at: { N: String(now() + Math.ceil(keepSeconds)) },\n },\n ConditionExpression: FREE_OR_OWNED,\n ExpressionAttributeNames,\n ExpressionAttributeValues: {\n ...values,\n ':now': { N: String(now()) },\n },\n });\n return true;\n } catch (error) {\n if (conditionFailed(error)) return false;\n throw error;\n }\n },\n async release(key, owner) {\n const { ExpressionAttributeNames, ...values } = owned(owner);\n try {\n await options.send('DeleteItem', {\n TableName: options.table,\n Key: { pk: pk(key) },\n ConditionExpression: '#state = :pending AND #owner = :owner',\n ExpressionAttributeNames,\n ExpressionAttributeValues: values,\n });\n } catch (error) {\n if (!conditionFailed(error)) throw error;\n }\n },\n };\n}\n","// Nonces and idempotent answers in PostgreSQL, shared by every instance.\n// Works with any client through one function that runs a parameterised\n// statement and returns its rows: node-postgres `(sql, params) => pool.query(sql, params)`,\n// postgres.js `(sql, params) => sql.unsafe(text, params).then((rows) => ({ rows }))`.\n// Create the tables once (`POSTGRES_TABLES`, `postgresTables(prefix)`, or your\n// migrations); expired rows are reused in place and pruned now and then.\nimport type {\n IdempotencyRecord,\n IdempotencyStore,\n} from '../tools/idempotency.js';\nimport type { NonceStore } from '../tools/nonces.js';\n\nexport type PostgresQuery = (\n sql: string,\n params: readonly unknown[],\n) => Promise<{ rows: readonly Record<string, unknown>[] }>;\n\nexport interface PostgresStoreOptions {\n query: PostgresQuery;\n /** Prune expired rows on one call in this many; default 1000. */\n pruneEvery?: number;\n /**\n * The tables' name prefix, default `vinaverse_` (`vinaverse_nonces`,\n * `vinaverse_idempotency`): a separate prefix per environment or signing\n * secret when they share a database. Lower-case letters, digits and `_`.\n */\n tablePrefix?: string;\n}\n\nconst PREFIX = /^[a-z_][a-z0-9_]{0,40}$/;\n\n/** The tables the stores use, under a prefix (default `vinaverse_`). */\nexport function postgresTables(tablePrefix = 'vinaverse_'): string {\n const t = tables(tablePrefix);\n return `\nCREATE TABLE IF NOT EXISTS ${t.nonces} (\n nonce text PRIMARY KEY,\n keep_until timestamptz NOT NULL\n);\nCREATE TABLE IF NOT EXISTS ${t.idempotency} (\n key text PRIMARY KEY,\n fingerprint text NOT NULL,\n state text NOT NULL CHECK (state IN ('pending', 'done')),\n owner text,\n status integer,\n body text,\n keep_until timestamptz NOT NULL\n);`;\n}\n\n/** The tables the stores use, with the default prefix. */\nexport const POSTGRES_TABLES = postgresTables();\n\nfunction tables(prefix: string): { nonces: string; idempotency: string } {\n if (!PREFIX.test(prefix)) {\n throw new TypeError(\n `tablePrefix must be lower-case letters, digits and _, starting with a letter or _, not ${JSON.stringify(prefix)}`,\n );\n }\n return { nonces: `${prefix}nonces`, idempotency: `${prefix}idempotency` };\n}\n\nfunction pruner(\n options: PostgresStoreOptions,\n sql: string,\n): () => Promise<void> {\n const every = options.pruneEvery ?? 1000;\n let count = 0;\n return async () => {\n count = (count + 1) % every;\n if (count === 0) await options.query(sql, []);\n };\n}\n\nexport function postgresNonceStore(options: PostgresStoreOptions): NonceStore {\n const { nonces } = tables(options.tablePrefix ?? 'vinaverse_');\n const claim = `INSERT INTO ${nonces} (nonce, keep_until) VALUES ($1, to_timestamp($2))\nON CONFLICT (nonce) DO UPDATE SET keep_until = EXCLUDED.keep_until\nWHERE ${nonces}.keep_until < now()\nRETURNING nonce`;\n const prune = pruner(\n options,\n `DELETE FROM ${nonces} WHERE keep_until < now()`,\n );\n return {\n async claim(nonce, keepUntil) {\n await prune();\n const { rows } = await options.query(claim, [nonce, keepUntil]);\n return rows.length === 1;\n },\n };\n}\n\nexport function postgresIdempotencyStore(\n options: PostgresStoreOptions,\n): IdempotencyStore {\n const { idempotency: t } = tables(options.tablePrefix ?? 'vinaverse_');\n const start = `INSERT INTO ${t} (key, fingerprint, state, owner, keep_until)\nVALUES ($1, $2, 'pending', $4, now() + make_interval(secs => $3))\nON CONFLICT (key) DO UPDATE SET fingerprint = EXCLUDED.fingerprint, state = 'pending', owner = EXCLUDED.owner,\n status = NULL, body = NULL, keep_until = EXCLUDED.keep_until\nWHERE ${t}.keep_until < now()\nRETURNING key`;\n const held = `SELECT fingerprint, state, status, body FROM ${t}\nWHERE key = $1 AND keep_until >= now()`;\n // Stored only when the key is free (absent or expired) or still this call's pending lease.\n const finish = `INSERT INTO ${t} (key, fingerprint, state, owner, status, body, keep_until)\nVALUES ($1, $2, 'done', NULL, $3, $4, now() + make_interval(secs => $5))\nON CONFLICT (key) DO UPDATE SET fingerprint = EXCLUDED.fingerprint, state = 'done', owner = NULL,\n status = EXCLUDED.status, body = EXCLUDED.body, keep_until = EXCLUDED.keep_until\nWHERE ${t}.keep_until < now() OR (${t}.state = 'pending' AND ${t}.owner = $6)\nRETURNING key`;\n const release = `DELETE FROM ${t} WHERE key = $1 AND state = 'pending' AND owner = $2`;\n const prune = pruner(options, `DELETE FROM ${t} WHERE keep_until < now()`);\n return {\n async start(key, fingerprint, pendingSeconds, owner) {\n await prune();\n for (;;) {\n const started = await options.query(start, [\n key,\n fingerprint,\n pendingSeconds,\n owner,\n ]);\n if (started.rows.length === 1) return null;\n const [row] = (await options.query(held, [key])).rows;\n // Lapsed between the two statements: try to take it again.\n if (row) {\n return {\n fingerprint: String(row.fingerprint),\n state: row.state === 'done' ? 'done' : 'pending',\n ...(row.status === null || row.status === undefined\n ? {}\n : { status: Number(row.status) }),\n ...(row.body === null || row.body === undefined\n ? {}\n : { body: String(row.body) }),\n } satisfies IdempotencyRecord;\n }\n }\n },\n async finish(key, record, keepSeconds, owner) {\n const { rows } = await options.query(finish, [\n key,\n record.fingerprint,\n record.status,\n record.body,\n keepSeconds,\n owner,\n ]);\n return rows.length === 1;\n },\n async release(key, owner) {\n await options.query(release, [key, owner]);\n },\n };\n}\n","// Nonces and idempotent answers in Redis, shared by every instance. Works with\n// any client through one function that sends a raw command:\n// node-redis: (args) => client.sendCommand(args)\n// ioredis: (args) => client.call(args[0], ...args.slice(1))\nimport type {\n IdempotencyRecord,\n IdempotencyStore,\n} from '../tools/idempotency.js';\nimport type { NonceStore } from '../tools/nonces.js';\n\n/** Sends one Redis command (`['SET', key, value, 'NX', 'EX', '60']`) and returns its reply. */\nexport type RedisCommand = (args: string[]) => Promise<unknown>;\n\nexport interface RedisStoreOptions {\n command: RedisCommand;\n /** Prefix for every key; default `vinaverse:`. */\n prefix?: string;\n}\n\nexport function redisNonceStore(options: RedisStoreOptions): NonceStore {\n const prefix = options.prefix ?? 'vinaverse:';\n return {\n async claim(nonce, keepUntil) {\n const reply = await options.command([\n 'SET',\n `${prefix}nonce:${nonce}`,\n '1',\n 'NX',\n 'EXAT',\n String(Math.ceil(keepUntil)),\n ]);\n return reply === 'OK';\n },\n };\n}\n\n// Atomically, on the record a call holds: a pending record goes only when this\n// call's `owner` holds it; an answer is stored only when the key is free or\n// still this call's pending lease (never over another call's, after this\n// call's lease lapsed).\nconst RELEASE_IF_OWNED = `local held = redis.call('GET', KEYS[1])\nif held then\n local r = cjson.decode(held)\n if r.state == 'pending' and r.owner == ARGV[1] then return redis.call('DEL', KEYS[1]) end\nend\nreturn 0`;\nconst FINISH_IF_OWNED = `local held = redis.call('GET', KEYS[1])\nif held then\n local r = cjson.decode(held)\n if r.state ~= 'pending' or r.owner ~= ARGV[1] then return 0 end\nend\nredis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])\nreturn 1`;\n\nexport function redisIdempotencyStore(\n options: RedisStoreOptions,\n): IdempotencyStore {\n const prefix = options.prefix ?? 'vinaverse:';\n const key = (k: string) => `${prefix}idempotency:${k}`;\n return {\n async start(k, fingerprint, pendingSeconds, owner) {\n for (;;) {\n const set = await options.command([\n 'SET',\n key(k),\n JSON.stringify({ fingerprint, state: 'pending', owner }),\n 'NX',\n 'EX',\n seconds(pendingSeconds),\n ]);\n if (set === 'OK') return null;\n const held = await options.command(['GET', key(k)]);\n // Gone between the two commands (it lapsed): try to take it again.\n if (typeof held === 'string') {\n const { owner: _, ...record } = JSON.parse(\n held,\n ) as IdempotencyRecord & {\n owner?: string;\n };\n return record;\n }\n }\n },\n async finish(k, record, keepSeconds, owner) {\n const stored = await options.command([\n 'EVAL',\n FINISH_IF_OWNED,\n '1',\n key(k),\n owner,\n JSON.stringify({ ...record, state: 'done' }),\n seconds(keepSeconds),\n ]);\n return Number(stored) === 1;\n },\n async release(k, owner) {\n await options.command(['EVAL', RELEASE_IF_OWNED, '1', key(k), owner]);\n },\n };\n}\n\nfunction seconds(value: number): string {\n return String(Math.max(1, Math.ceil(value)));\n}\n"]}
|
package/dist/testing.cjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/webhooks/crypto.ts
|
|
4
|
+
var WebCryptoUnavailableError = class extends Error {
|
|
5
|
+
constructor() {
|
|
6
|
+
super(
|
|
7
|
+
"Web Crypto (crypto.subtle) is not available in this runtime; @vinaverse/server needs Node 18+, an edge runtime or another runtime with Web Crypto"
|
|
8
|
+
);
|
|
9
|
+
this.name = "WebCryptoUnavailableError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var fromNode;
|
|
13
|
+
function webCrypto() {
|
|
14
|
+
const global = globalThis.crypto;
|
|
15
|
+
if (global?.subtle) return Promise.resolve(global);
|
|
16
|
+
fromNode ??= nodeWebCrypto();
|
|
17
|
+
return fromNode;
|
|
18
|
+
}
|
|
19
|
+
async function subtleCrypto() {
|
|
20
|
+
return (await webCrypto()).subtle;
|
|
21
|
+
}
|
|
22
|
+
async function nodeWebCrypto() {
|
|
23
|
+
if (typeof globalThis.process?.versions?.node !== "string") {
|
|
24
|
+
throw new WebCryptoUnavailableError();
|
|
25
|
+
}
|
|
26
|
+
const specifier = ["node", "crypto"].join(":");
|
|
27
|
+
const mod = await import(
|
|
28
|
+
/* webpackIgnore: true */
|
|
29
|
+
/* @vite-ignore */
|
|
30
|
+
specifier
|
|
31
|
+
);
|
|
32
|
+
if (!mod.webcrypto?.subtle) throw new WebCryptoUnavailableError();
|
|
33
|
+
return mod.webcrypto;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/internal/bytes.ts
|
|
37
|
+
var encoder = new TextEncoder();
|
|
38
|
+
function utf8(text) {
|
|
39
|
+
return encoder.encode(text);
|
|
40
|
+
}
|
|
41
|
+
function bytesToHex(bytes) {
|
|
42
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
43
|
+
}
|
|
44
|
+
async function sha256Hex(data) {
|
|
45
|
+
const subtle = await subtleCrypto();
|
|
46
|
+
return bytesToHex(new Uint8Array(await subtle.digest("SHA-256", copy(data))));
|
|
47
|
+
}
|
|
48
|
+
async function hmacKey(secret, usage) {
|
|
49
|
+
const subtle = await subtleCrypto();
|
|
50
|
+
return subtle.importKey(
|
|
51
|
+
"raw",
|
|
52
|
+
utf8(secret),
|
|
53
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
54
|
+
false,
|
|
55
|
+
[usage]
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
function copy(data) {
|
|
59
|
+
const out = new Uint8Array(data.length);
|
|
60
|
+
out.set(data);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
function concat(...parts) {
|
|
64
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
65
|
+
let at = 0;
|
|
66
|
+
for (const p of parts) {
|
|
67
|
+
out.set(p, at);
|
|
68
|
+
at += p.length;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/tools/signature.ts
|
|
74
|
+
async function toolSigningString(call) {
|
|
75
|
+
const bodyHash = call.body && call.body.length > 0 ? await sha256Hex(call.body) : "";
|
|
76
|
+
return utf8(
|
|
77
|
+
[
|
|
78
|
+
"v2",
|
|
79
|
+
String(call.t),
|
|
80
|
+
call.n,
|
|
81
|
+
call.method.toUpperCase(),
|
|
82
|
+
call.target,
|
|
83
|
+
await sha256Hex(utf8(call.token)),
|
|
84
|
+
bodyHash
|
|
85
|
+
].join("\n")
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/testing.ts
|
|
90
|
+
async function signWebhook(options) {
|
|
91
|
+
const t = Math.floor((options.timestamp ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
|
|
92
|
+
const body = typeof options.body === "string" ? utf8(options.body) : options.body;
|
|
93
|
+
const message = concat(utf8(`${t}.`), body);
|
|
94
|
+
const subtle = await subtleCrypto();
|
|
95
|
+
const secrets = typeof options.secret === "string" ? [options.secret] : options.secret;
|
|
96
|
+
const parts = [`t=${t}`];
|
|
97
|
+
for (const secret of secrets) {
|
|
98
|
+
const key = await hmacKey(secret, "sign");
|
|
99
|
+
const mac = new Uint8Array(await subtle.sign("HMAC", key, message));
|
|
100
|
+
parts.push(`v1=${bytesToHex(mac)}`);
|
|
101
|
+
}
|
|
102
|
+
return parts.join(",");
|
|
103
|
+
}
|
|
104
|
+
async function signToolCall(options) {
|
|
105
|
+
const t = Math.floor((options.timestamp ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
|
|
106
|
+
const n = options.nonce ?? await freshNonce();
|
|
107
|
+
const body = typeof options.body === "string" ? utf8(options.body) : options.body;
|
|
108
|
+
const message = await toolSigningString({
|
|
109
|
+
t,
|
|
110
|
+
n,
|
|
111
|
+
method: options.method,
|
|
112
|
+
target: options.target,
|
|
113
|
+
token: options.token,
|
|
114
|
+
body
|
|
115
|
+
});
|
|
116
|
+
const subtle = await subtleCrypto();
|
|
117
|
+
const secrets = typeof options.secret === "string" ? [options.secret] : options.secret;
|
|
118
|
+
const parts = [`t=${t}`, `n=${n}`];
|
|
119
|
+
for (const secret of secrets) {
|
|
120
|
+
const key = await hmacKey(secret, "sign");
|
|
121
|
+
parts.push(
|
|
122
|
+
`v2=${bytesToHex(new Uint8Array(await subtle.sign("HMAC", key, message)))}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return parts.join(",");
|
|
126
|
+
}
|
|
127
|
+
async function freshNonce() {
|
|
128
|
+
const random = (await webCrypto()).getRandomValues(new Uint8Array(24));
|
|
129
|
+
return btoa(String.fromCharCode(...random)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
exports.signToolCall = signToolCall;
|
|
133
|
+
exports.signWebhook = signWebhook;
|
|
134
|
+
//# sourceMappingURL=testing.cjs.map
|
|
135
|
+
//# sourceMappingURL=testing.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/webhooks/crypto.ts","../src/internal/bytes.ts","../src/tools/signature.ts","../src/testing.ts"],"names":[],"mappings":"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","file":"testing.cjs","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","// Tool-call signature v2, exactly as the Vinaverse API signs it and as its\n// reference verifier checks it in strict mode (contract: tool-provider-v1.yaml,\n// `vinaverseSignature`; API: vvcore.connectors.signature):\n// Vinaverse-Signature: t=<unix seconds>,n=<nonce>,v2=<hex>[,v2=<hex>][,v1=<hex>]\n// `v2` is the lowercase hex HMAC-SHA256, keyed with the tool signing secret as\n// UTF-8, over seven lines joined by \"\\n\" with no trailing line feed:\n// v2, t, n, the METHOD, the request target exactly as sent (path and query,\n// still percent-escaped), the hex SHA-256 of the whole delegated token, and\n// the hex SHA-256 of the body, or an empty line when there is no body.\n// A signature more than 300 seconds from now is refused, and so is a nonce\n// already taken. `v1` (sent during the transition) is ignored: it binds\n// neither the token nor a nonce.\nimport { hexToBytes, hmacKey, sha256Hex, utf8 } from '../internal/bytes.js';\nimport { subtleCrypto } from '../webhooks/crypto.js';\nimport { checkTolerance } from '../webhooks/verify.js';\nimport { NONCE_KEEP_SECONDS, type NonceStore } from './nonces.js';\n\nexport const TOOL_TOLERANCE_SECONDS = 300;\n\nconst T = /^[0-9]{1,12}$/;\nconst NONCE = /^[A-Za-z0-9_-]{16,64}$/;\nconst MAC = /^[0-9a-f]{64}$/;\n\n/**\n * Why a tool call's signature was refused, in the conformance vectors' words\n * (vinaverse-contracts `conformance/tool-calls.json`), checked in their order:\n * - `missing`: no `Vinaverse-Signature` header.\n * - `malformed`: no valid `t`, or no valid nonce (16 to 64 of `A-Z a-z 0-9 - _`).\n * - `stale`: signed more than 300 seconds from now, either way.\n * - `v2_required`: no `v2` (a v1-only header binds neither the token nor a nonce).\n * - `mismatch`: no `v2` matches with any secret: a wrong secret, or a method,\n * target, delegated token or body other than the ones signed.\n * - `replayed`: a valid signature whose nonce was already taken.\n */\nexport type ToolSignatureErrorCode =\n | 'missing'\n | 'malformed'\n | 'stale'\n | 'v2_required'\n | 'mismatch'\n | 'replayed';\n\nexport interface ToolSignatureError {\n readonly code: ToolSignatureErrorCode;\n readonly message: string;\n}\n\nexport interface ToolCallToVerify {\n /** The HTTP method. */\n method: string;\n /** The request target exactly as received: path and query, still percent-escaped. */\n target: string;\n /** The delegated token from the `Authorization: Bearer` header. */\n token: string;\n /** The body as received; `undefined`, `null` or empty for none. */\n body?: Uint8Array | null | undefined;\n /** The `Vinaverse-Signature` header. */\n header: string | null | undefined;\n}\n\nexport interface VerifyToolCallOptions {\n /** The tool signing secret, or two during a rotation. */\n secrets: readonly string[];\n nonces: NonceStore;\n now: Date;\n toleranceSeconds?: number;\n}\n\n/**\n * The fields the header carries. A repeated `t` or `n` makes the header\n * ambiguous, so it is refused (`repeated`), never resolved by order (VIN-154).\n */\nexport function parseToolSignature(header: string): {\n t: number | undefined;\n n: string | undefined;\n v2: string[];\n repeated: boolean;\n} {\n let t: number | undefined;\n let n: string | undefined;\n let seenT = 0;\n let seenN = 0;\n const v2: string[] = [];\n for (const part of header.split(',')) {\n const trimmed = part.trim();\n const at = trimmed.indexOf('=');\n const key = at < 0 ? trimmed : trimmed.slice(0, at);\n const value = at < 0 ? '' : trimmed.slice(at + 1);\n if (key === 't') {\n seenT++;\n if (T.test(value)) t = Number(value);\n } else if (key === 'n') {\n seenN++;\n n = value;\n } else if (key === 'v2' && MAC.test(value)) {\n v2.push(value);\n }\n }\n return { t, n, v2, repeated: seenT > 1 || seenN > 1 };\n}\n\n/** The string v2 signs. */\nexport async function toolSigningString(call: {\n t: number;\n n: string;\n method: string;\n target: string;\n token: string;\n body?: Uint8Array | null | undefined;\n}): Promise<Uint8Array<ArrayBuffer>> {\n const bodyHash =\n call.body && call.body.length > 0 ? await sha256Hex(call.body) : '';\n return utf8(\n [\n 'v2',\n String(call.t),\n call.n,\n call.method.toUpperCase(),\n call.target,\n await sha256Hex(utf8(call.token)),\n bodyHash,\n ].join('\\n'),\n );\n}\n\n/**\n * Checks a tool call's v2 signature and takes its nonce. Returns null when the\n * call is authentic and fresh. The nonce is claimed only after a signature\n * matched, so a forged header cannot burn a real call's nonce.\n */\nexport async function verifyToolCall(\n call: ToolCallToVerify,\n options: VerifyToolCallOptions,\n): Promise<ToolSignatureError | null> {\n if (!call.header) {\n return refusal('missing', 'no Vinaverse-Signature header');\n }\n const { t, n, v2, repeated } = parseToolSignature(call.header);\n if (repeated) {\n return refusal(\n 'malformed',\n 'the signature repeats t or n: an ambiguous header',\n );\n }\n if (t === undefined) {\n return refusal(\n 'malformed',\n 'the signature has no timestamp t=<unix seconds>',\n );\n }\n const now = Math.floor(options.now.getTime() / 1000);\n const tolerance =\n options.toleranceSeconds === undefined\n ? TOOL_TOLERANCE_SECONDS\n : checkTolerance(options.toleranceSeconds);\n if (Math.abs(now - t) > tolerance) {\n return refusal(\n 'stale',\n `the signature is ${Math.abs(now - t)} seconds from now; at most ${tolerance} are accepted`,\n );\n }\n if (v2.length === 0) {\n return refusal(\n 'v2_required',\n 'the signature has no v2 (a v1-only header binds neither the token nor a nonce)',\n );\n }\n if (n === undefined || !NONCE.test(n)) {\n return refusal(\n 'malformed',\n 'the signature has no valid nonce n (16 to 64 of A-Z a-z 0-9 - _)',\n );\n }\n const message = await toolSigningString({ ...call, t, n });\n const subtle = await subtleCrypto();\n let matched = false;\n for (const secret of options.secrets) {\n const key = await hmacKey(secret, 'verify');\n for (const mac of v2) {\n if (await subtle.verify('HMAC', key, hexToBytes(mac), message)) {\n matched = true;\n break;\n }\n }\n if (matched) break;\n }\n if (!matched) {\n return refusal(\n 'mismatch',\n 'no v2 signature matches: check the tool signing secret, and verify the method, target, token and body exactly as received',\n );\n }\n if (!(await options.nonces.claim(n, Math.max(t, now) + NONCE_KEEP_SECONDS))) {\n return refusal('replayed', 'this nonce was already used: a replayed call');\n }\n return null;\n}\n\nfunction refusal(\n code: ToolSignatureErrorCode,\n message: string,\n): ToolSignatureError {\n return { code, message };\n}\n","// For the tenant's own tests: sign a body the way Vinaverse does, so a test can\n// deliver a webhook to its route without the Vinaverse API.\n//\n// import { signWebhook } from '@vinaverse/server/testing';\n// const header = await signWebhook({ body, secret });\nimport { bytesToHex, concat, hmacKey, utf8 } from './internal/bytes.js';\nimport { toolSigningString } from './tools/signature.js';\nimport { subtleCrypto, webCrypto } from './webhooks/crypto.js';\n\nexport interface SignWebhookOptions {\n /** The exact body to send. */\n body: string | Uint8Array;\n /** One signing secret, or several (one `v1` each, as during a rotation). */\n secret: string | readonly string[];\n /** The signature's time; default now. */\n timestamp?: Date;\n}\n\n/** The `Vinaverse-Signature` header value for `body`. */\nexport async function signWebhook(\n options: SignWebhookOptions,\n): Promise<string> {\n const t = Math.floor((options.timestamp ?? new Date()).getTime() / 1000);\n const body =\n typeof options.body === 'string' ? utf8(options.body) : options.body;\n const message = concat(utf8(`${t}.`), body);\n const subtle = await subtleCrypto();\n const secrets =\n typeof options.secret === 'string' ? [options.secret] : options.secret;\n const parts = [`t=${t}`];\n for (const secret of secrets) {\n const key = await hmacKey(secret, 'sign');\n const mac = new Uint8Array(await subtle.sign('HMAC', key, message));\n parts.push(`v1=${bytesToHex(mac)}`);\n }\n return parts.join(',');\n}\n\nexport interface SignToolCallOptions {\n /** The tool signing secret, or several (one `v2` each, as during a rotation). */\n secret: string | readonly string[];\n method: string;\n /** The request target as it will be sent: path and query, percent-escaped. */\n target: string;\n /** The session's delegated token. */\n token: string;\n /** The exact body, if any. */\n body?: string | Uint8Array | undefined;\n /** The signature's time; default now. */\n timestamp?: Date;\n /** The nonce; default a fresh one (32 URL-safe characters). */\n nonce?: string;\n}\n\n/** The `Vinaverse-Signature` value (signature v2) Vinaverse would send with this tool call. */\nexport async function signToolCall(\n options: SignToolCallOptions,\n): Promise<string> {\n const t = Math.floor((options.timestamp ?? new Date()).getTime() / 1000);\n const n = options.nonce ?? (await freshNonce());\n const body =\n typeof options.body === 'string' ? utf8(options.body) : options.body;\n const message = await toolSigningString({\n t,\n n,\n method: options.method,\n target: options.target,\n token: options.token,\n body,\n });\n const subtle = await subtleCrypto();\n const secrets =\n typeof options.secret === 'string' ? [options.secret] : options.secret;\n const parts = [`t=${t}`, `n=${n}`];\n for (const secret of secrets) {\n const key = await hmacKey(secret, 'sign');\n parts.push(\n `v2=${bytesToHex(new Uint8Array(await subtle.sign('HMAC', key, message)))}`,\n );\n }\n return parts.join(',');\n}\n\nasync function freshNonce(): Promise<string> {\n const random = (await webCrypto()).getRandomValues(new Uint8Array(24));\n return btoa(String.fromCharCode(...random))\n .replaceAll('+', '-')\n .replaceAll('/', '_')\n .replaceAll('=', '');\n}\n"]}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
interface SignWebhookOptions {
|
|
2
|
+
/** The exact body to send. */
|
|
3
|
+
body: string | Uint8Array;
|
|
4
|
+
/** One signing secret, or several (one `v1` each, as during a rotation). */
|
|
5
|
+
secret: string | readonly string[];
|
|
6
|
+
/** The signature's time; default now. */
|
|
7
|
+
timestamp?: Date;
|
|
8
|
+
}
|
|
9
|
+
/** The `Vinaverse-Signature` header value for `body`. */
|
|
10
|
+
declare function signWebhook(options: SignWebhookOptions): Promise<string>;
|
|
11
|
+
interface SignToolCallOptions {
|
|
12
|
+
/** The tool signing secret, or several (one `v2` each, as during a rotation). */
|
|
13
|
+
secret: string | readonly string[];
|
|
14
|
+
method: string;
|
|
15
|
+
/** The request target as it will be sent: path and query, percent-escaped. */
|
|
16
|
+
target: string;
|
|
17
|
+
/** The session's delegated token. */
|
|
18
|
+
token: string;
|
|
19
|
+
/** The exact body, if any. */
|
|
20
|
+
body?: string | Uint8Array | undefined;
|
|
21
|
+
/** The signature's time; default now. */
|
|
22
|
+
timestamp?: Date;
|
|
23
|
+
/** The nonce; default a fresh one (32 URL-safe characters). */
|
|
24
|
+
nonce?: string;
|
|
25
|
+
}
|
|
26
|
+
/** The `Vinaverse-Signature` value (signature v2) Vinaverse would send with this tool call. */
|
|
27
|
+
declare function signToolCall(options: SignToolCallOptions): Promise<string>;
|
|
28
|
+
|
|
29
|
+
export { type SignToolCallOptions, type SignWebhookOptions, signToolCall, signWebhook };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
interface SignWebhookOptions {
|
|
2
|
+
/** The exact body to send. */
|
|
3
|
+
body: string | Uint8Array;
|
|
4
|
+
/** One signing secret, or several (one `v1` each, as during a rotation). */
|
|
5
|
+
secret: string | readonly string[];
|
|
6
|
+
/** The signature's time; default now. */
|
|
7
|
+
timestamp?: Date;
|
|
8
|
+
}
|
|
9
|
+
/** The `Vinaverse-Signature` header value for `body`. */
|
|
10
|
+
declare function signWebhook(options: SignWebhookOptions): Promise<string>;
|
|
11
|
+
interface SignToolCallOptions {
|
|
12
|
+
/** The tool signing secret, or several (one `v2` each, as during a rotation). */
|
|
13
|
+
secret: string | readonly string[];
|
|
14
|
+
method: string;
|
|
15
|
+
/** The request target as it will be sent: path and query, percent-escaped. */
|
|
16
|
+
target: string;
|
|
17
|
+
/** The session's delegated token. */
|
|
18
|
+
token: string;
|
|
19
|
+
/** The exact body, if any. */
|
|
20
|
+
body?: string | Uint8Array | undefined;
|
|
21
|
+
/** The signature's time; default now. */
|
|
22
|
+
timestamp?: Date;
|
|
23
|
+
/** The nonce; default a fresh one (32 URL-safe characters). */
|
|
24
|
+
nonce?: string;
|
|
25
|
+
}
|
|
26
|
+
/** The `Vinaverse-Signature` value (signature v2) Vinaverse would send with this tool call. */
|
|
27
|
+
declare function signToolCall(options: SignToolCallOptions): Promise<string>;
|
|
28
|
+
|
|
29
|
+
export { type SignToolCallOptions, type SignWebhookOptions, signToolCall, signWebhook };
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { toolSigningString } from './chunk-SDJFZPHJ.js';
|
|
2
|
+
import { utf8, concat, subtleCrypto, hmacKey, bytesToHex, webCrypto } from './chunk-GTPMQKGX.js';
|
|
3
|
+
|
|
4
|
+
// src/testing.ts
|
|
5
|
+
async function signWebhook(options) {
|
|
6
|
+
const t = Math.floor((options.timestamp ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
|
|
7
|
+
const body = typeof options.body === "string" ? utf8(options.body) : options.body;
|
|
8
|
+
const message = concat(utf8(`${t}.`), body);
|
|
9
|
+
const subtle = await subtleCrypto();
|
|
10
|
+
const secrets = typeof options.secret === "string" ? [options.secret] : options.secret;
|
|
11
|
+
const parts = [`t=${t}`];
|
|
12
|
+
for (const secret of secrets) {
|
|
13
|
+
const key = await hmacKey(secret, "sign");
|
|
14
|
+
const mac = new Uint8Array(await subtle.sign("HMAC", key, message));
|
|
15
|
+
parts.push(`v1=${bytesToHex(mac)}`);
|
|
16
|
+
}
|
|
17
|
+
return parts.join(",");
|
|
18
|
+
}
|
|
19
|
+
async function signToolCall(options) {
|
|
20
|
+
const t = Math.floor((options.timestamp ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
|
|
21
|
+
const n = options.nonce ?? await freshNonce();
|
|
22
|
+
const body = typeof options.body === "string" ? utf8(options.body) : options.body;
|
|
23
|
+
const message = await toolSigningString({
|
|
24
|
+
t,
|
|
25
|
+
n,
|
|
26
|
+
method: options.method,
|
|
27
|
+
target: options.target,
|
|
28
|
+
token: options.token,
|
|
29
|
+
body
|
|
30
|
+
});
|
|
31
|
+
const subtle = await subtleCrypto();
|
|
32
|
+
const secrets = typeof options.secret === "string" ? [options.secret] : options.secret;
|
|
33
|
+
const parts = [`t=${t}`, `n=${n}`];
|
|
34
|
+
for (const secret of secrets) {
|
|
35
|
+
const key = await hmacKey(secret, "sign");
|
|
36
|
+
parts.push(
|
|
37
|
+
`v2=${bytesToHex(new Uint8Array(await subtle.sign("HMAC", key, message)))}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return parts.join(",");
|
|
41
|
+
}
|
|
42
|
+
async function freshNonce() {
|
|
43
|
+
const random = (await webCrypto()).getRandomValues(new Uint8Array(24));
|
|
44
|
+
return btoa(String.fromCharCode(...random)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { signToolCall, signWebhook };
|
|
48
|
+
//# sourceMappingURL=testing.js.map
|
|
49
|
+
//# sourceMappingURL=testing.js.map
|