@query-farm/vgi 0.40.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/attach-ticket.d.ts +179 -0
- package/dist/catalog/attach-option.d.ts +30 -0
- package/dist/client-entry.js +1 -1
- package/dist/client-entry.js.map +3 -3
- package/dist/crypto.d.ts +5 -1
- package/dist/http/fetch.d.ts +6 -0
- package/dist/index.core.d.ts +2 -1
- package/dist/index.js +1937 -1447
- package/dist/index.js.map +15 -14
- package/dist/rpc-server.d.ts +14 -0
- package/dist/serve-entry.js +527 -10
- package/dist/serve-entry.js.map +10 -8
- package/dist/worker-cf-entry.js +573 -62
- package/dist/worker-cf-entry.js.map +11 -10
- package/dist/worker.d.ts +1 -0
- package/package.json +3 -3
- package/src/attach-ticket.ts +611 -0
- package/src/catalog/attach-option.ts +75 -0
- package/src/catalog/composite.ts +5 -1
- package/src/crypto.ts +13 -1
- package/src/http/fetch.ts +23 -1
- package/src/index.core.ts +27 -1
- package/src/protocol/handlers/catalog/admin.ts +21 -1
- package/src/rpc-server.ts +50 -5
- package/src/serve-entry.ts +4 -0
- package/src/worker.ts +13 -0
package/dist/worker.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export declare class Worker {
|
|
|
27
27
|
private _catalogName?;
|
|
28
28
|
constructor(config: WorkerConfig);
|
|
29
29
|
run(argv?: readonly string[]): void;
|
|
30
|
+
private _serve;
|
|
30
31
|
/**
|
|
31
32
|
* Build the vgi-rpc server for one transport: `vgi.v2`, the worker's
|
|
32
33
|
* `hostedProtocols()` and reflection everywhere; identity on HTTP only. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@query-farm/vgi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"description": "TypeScript framework for extending DuckDB with user-defined functions via the Vector Gateway Interface (VGI) over Arrow IPC.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Query Farm LLC (https://query.farm)",
|
|
@@ -91,12 +91,12 @@
|
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|
|
93
93
|
"@query-farm/apache-arrow": "^21.1.1",
|
|
94
|
-
"@query-farm/vgi-rpc": "^0.
|
|
94
|
+
"@query-farm/vgi-rpc": "^0.28.0"
|
|
95
95
|
},
|
|
96
96
|
"devDependencies": {
|
|
97
97
|
"@duckdb/node-api": "^1.5.5-r.4",
|
|
98
98
|
"@query-farm/apache-arrow": "^21.1.1",
|
|
99
|
-
"@query-farm/vgi-rpc": "^0.
|
|
99
|
+
"@query-farm/vgi-rpc": "^0.28.0",
|
|
100
100
|
"@types/bun": "latest",
|
|
101
101
|
"typescript": "^7.0.2"
|
|
102
102
|
},
|
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: LicenseRef-QueryFarm-Source-Available-1.0
|
|
3
|
+
|
|
4
|
+
// Attach tickets: a user's ATTACH, sealed so a runner can replay it later as
|
|
5
|
+
// that user (`vgi.attach_tickets.v1`).
|
|
6
|
+
//
|
|
7
|
+
// A ticket is the *what* half of an unattended session; a sealed grant
|
|
8
|
+
// (`vgi_rpc.Identity.v1` `issue_grant`) is the *who*. While the user is
|
|
9
|
+
// attached and logged in, the client asks the worker to seal the options it
|
|
10
|
+
// attached with -- secret ones included -- into a ticket only this worker can
|
|
11
|
+
// open. Later any runner holding the user's grant attaches with the single
|
|
12
|
+
// option `vgi_attach_ticket`, and `catalog_attach` restores the sealed attach
|
|
13
|
+
// before any catalog code runs. The runner never sees an option.
|
|
14
|
+
//
|
|
15
|
+
// A ticket carries no authority: it opens only under the *caller's* principal.
|
|
16
|
+
//
|
|
17
|
+
// Normative spec: vgi-python `docs/protocol/vgi-attach-tickets.md`. Byte-exact
|
|
18
|
+
// vectors: `src/__tests__/data/attach_ticket_vectors.json`.
|
|
19
|
+
//
|
|
20
|
+
// token = "vgia1." || base64url_nopad(envelope)
|
|
21
|
+
// envelope = 0x01 || nonce(24) || XChaCha20-Poly1305(key, nonce, payload, aad)
|
|
22
|
+
// key = this worker's signing key (the attach_opaque_data key), normalized
|
|
23
|
+
// aad = "vgi.attach_ticket.v1" 0x00 || UTF-8(principal)
|
|
24
|
+
//
|
|
25
|
+
// The AAD binds the principal only, not the (domain, principal) pair the
|
|
26
|
+
// attach envelope binds: a ticket is sealed while the user is logged in
|
|
27
|
+
// (domain `jwt`, say) and opened when a runner presents their grant (domain
|
|
28
|
+
// `grant`).
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
type AuthContext,
|
|
32
|
+
type CallContext,
|
|
33
|
+
Protocol,
|
|
34
|
+
badRequest,
|
|
35
|
+
errorInfo,
|
|
36
|
+
preconditionFailure,
|
|
37
|
+
} from "@query-farm/vgi-rpc";
|
|
38
|
+
import { field, float64, schema, utf8, binary, int64, batchFromColumns, serializeBatch } from "./arrow/index.js";
|
|
39
|
+
import { deserializeBatch } from "./util/arrow/index.js";
|
|
40
|
+
import { openBytes, OpaqueDataRejectedError, sealBytes } from "./crypto.js";
|
|
41
|
+
import type { CatalogInterface } from "./catalog/interface.js";
|
|
42
|
+
import { deserializeAttachOptionSpecs, RESERVED_ATTACH_OPTION } from "./catalog/attach-option.js";
|
|
43
|
+
import { toUint8Array } from "./util/bytes.js";
|
|
44
|
+
import { decodeOptionsBatch } from "./protocol/handlers/catalog/shared.js";
|
|
45
|
+
import { REQUEST_PARAMS_SCHEMA, RESULT_BINARY_SCHEMA, unwrapRequest } from "./protocol/handlers/shared.js";
|
|
46
|
+
|
|
47
|
+
/** Token prefix. The version is in the prefix, so an incompatible format is a
|
|
48
|
+
* different prefix -- never half-parsed. */
|
|
49
|
+
export const ATTACH_TICKET_PREFIX = "vgia1.";
|
|
50
|
+
/** The reserved ATTACH option a runner presents a ticket in. */
|
|
51
|
+
export const ATTACH_TICKET_OPTION = RESERVED_ATTACH_OPTION;
|
|
52
|
+
/** Wire name of the protocol hosting `seal_attach`. */
|
|
53
|
+
export const ATTACH_TICKETS_PROTOCOL_NAME = "vgi.attach_tickets.v1";
|
|
54
|
+
/** Its declared version. */
|
|
55
|
+
export const ATTACH_TICKETS_PROTOCOL_VERSION = "1.0.0";
|
|
56
|
+
/** The envelope's version byte, fixed by this format. */
|
|
57
|
+
export const TICKET_ENVELOPE_VERSION = 0x01;
|
|
58
|
+
/** Largest options record (serialized Arrow IPC bytes) a ticket may carry. */
|
|
59
|
+
export const MAX_OPTIONS_BYTES = 16 * 1024;
|
|
60
|
+
/** Longest ticket text considered at all. */
|
|
61
|
+
export const MAX_TICKET_CHARS = 32 * 1024;
|
|
62
|
+
/** Allowance for clocks disagreeing between the sealing and redeeming worker. */
|
|
63
|
+
export const CLOCK_SKEW_SECONDS = 60;
|
|
64
|
+
|
|
65
|
+
const UTF8 = new TextEncoder();
|
|
66
|
+
const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
67
|
+
const TICKET_AAD_DOMAIN = UTF8.encode("vgi.attach_ticket.v1\0");
|
|
68
|
+
const TICKET_ID = /^[0-9a-f]{32}$/;
|
|
69
|
+
const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
70
|
+
const MAX_TEXT = 0xffff;
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Errors
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A `vgi_attach_ticket` this worker cannot accept.
|
|
78
|
+
*
|
|
79
|
+
* One type for every cause -- malformed, wrong prefix, non-canonical, wrong
|
|
80
|
+
* key, wrong principal, tampered, bad payload -- so a caller cannot tell a
|
|
81
|
+
* forged ticket from another user's. The message never contains the ticket.
|
|
82
|
+
*/
|
|
83
|
+
export class AttachTicketInvalidError extends Error {
|
|
84
|
+
static readonly errorKind = "attach_ticket_invalid";
|
|
85
|
+
readonly errorKind = "attach_ticket_invalid";
|
|
86
|
+
readonly errorCode = "INVALID_ARGUMENT";
|
|
87
|
+
readonly errorDetails: Record<string, unknown>[];
|
|
88
|
+
constructor(detail = "attach ticket not accepted") {
|
|
89
|
+
super(detail);
|
|
90
|
+
this.name = "AttachTicketInvalidError";
|
|
91
|
+
this.errorDetails = [badRequest([{ field: ATTACH_TICKET_OPTION, description: detail }]) as never];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* An authentic ticket outside its lifetime. Only raised once the ticket has
|
|
97
|
+
* opened under the caller's principal, so it reveals nothing a forger could use.
|
|
98
|
+
*/
|
|
99
|
+
export class AttachTicketExpiredError extends Error {
|
|
100
|
+
static readonly errorKind = "attach_ticket_expired";
|
|
101
|
+
readonly errorKind = "attach_ticket_expired";
|
|
102
|
+
readonly errorCode = "FAILED_PRECONDITION";
|
|
103
|
+
readonly errorDetails: Record<string, unknown>[];
|
|
104
|
+
constructor(detail = "attach ticket has expired") {
|
|
105
|
+
super(detail);
|
|
106
|
+
this.name = "AttachTicketExpiredError";
|
|
107
|
+
this.errorDetails = [
|
|
108
|
+
preconditionFailure([{ type: "ATTACH_TICKET", subject: ATTACH_TICKET_OPTION, description: detail }]) as never,
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `invalid_request` / `INVALID_ARGUMENT` with one `BadRequest` violation per bad field. */
|
|
114
|
+
export class AttachTicketRequestError extends Error {
|
|
115
|
+
readonly errorKind = "invalid_request";
|
|
116
|
+
readonly errorCode = "INVALID_ARGUMENT";
|
|
117
|
+
readonly errorDetails: Record<string, unknown>[];
|
|
118
|
+
constructor(message: string, violations: ReadonlyArray<readonly [string, string]>) {
|
|
119
|
+
super(message);
|
|
120
|
+
this.name = "AttachTicketRequestError";
|
|
121
|
+
this.errorDetails = [
|
|
122
|
+
badRequest(violations.map(([f, description]) => ({ field: f, description }))) as never,
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `action_denied` / `PERMISSION_DENIED`, naming the refused action. */
|
|
128
|
+
export class SealAttachDeniedError extends Error {
|
|
129
|
+
readonly errorKind = "action_denied";
|
|
130
|
+
readonly errorCode = "PERMISSION_DENIED";
|
|
131
|
+
readonly errorDetails: Record<string, unknown>[] = [errorInfo({ action: "seal_attach" }) as never];
|
|
132
|
+
constructor(message: string) {
|
|
133
|
+
super(message);
|
|
134
|
+
this.name = "SealAttachDeniedError";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Token format
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/** What a ticket carries. */
|
|
143
|
+
export interface AttachTicketClaims {
|
|
144
|
+
/** Seconds since the Unix epoch. */
|
|
145
|
+
issuedAt: number;
|
|
146
|
+
/** Seconds since the Unix epoch; `0` means no expiry. */
|
|
147
|
+
expiresAt: number;
|
|
148
|
+
/** 32 lowercase hex; a correlation handle, not a secret. */
|
|
149
|
+
ticketId: string;
|
|
150
|
+
catalogName: string;
|
|
151
|
+
/** `""` when the user gave none. */
|
|
152
|
+
dataVersionSpec: string;
|
|
153
|
+
/** `""` when the user gave none. */
|
|
154
|
+
implementationVersion: string;
|
|
155
|
+
/** Arrow IPC stream of the one-row options record; empty for none. */
|
|
156
|
+
optionsIpc: Uint8Array;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The ticket AAD: `"vgi.attach_ticket.v1" 0x00 || UTF-8(principal)`. */
|
|
160
|
+
export function attachTicketAad(principal: string): Uint8Array {
|
|
161
|
+
const p = UTF8.encode(principal);
|
|
162
|
+
const out = new Uint8Array(TICKET_AAD_DOMAIN.length + p.length);
|
|
163
|
+
out.set(TICKET_AAD_DOMAIN, 0);
|
|
164
|
+
out.set(p, TICKET_AAD_DOMAIN.length);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function packText(value: string, name: string): Uint8Array {
|
|
169
|
+
const raw = UTF8.encode(value);
|
|
170
|
+
if (raw.length > MAX_TEXT) throw new Error(`${name} is longer than 65535 bytes`);
|
|
171
|
+
const out = new Uint8Array(2 + raw.length);
|
|
172
|
+
new DataView(out.buffer).setUint16(0, raw.length, true);
|
|
173
|
+
out.set(raw, 2);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Encode a ticket payload (§2.1). Exported for the vectors' byte-exact
|
|
178
|
+
* `payload_hex` check; not part of the public API. @internal */
|
|
179
|
+
export function encodeAttachTicketPayload(c: AttachTicketClaims): Uint8Array {
|
|
180
|
+
return encodePayload(c);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function encodePayload(c: AttachTicketClaims): Uint8Array {
|
|
184
|
+
if (c.optionsIpc.length > MAX_OPTIONS_BYTES) {
|
|
185
|
+
throw new Error(`options are ${c.optionsIpc.length} bytes; a ticket carries at most ${MAX_OPTIONS_BYTES}`);
|
|
186
|
+
}
|
|
187
|
+
const head = new Uint8Array(16);
|
|
188
|
+
const hv = new DataView(head.buffer);
|
|
189
|
+
hv.setBigInt64(0, BigInt(c.issuedAt), true);
|
|
190
|
+
hv.setBigInt64(8, BigInt(c.expiresAt), true);
|
|
191
|
+
const optLen = new Uint8Array(4);
|
|
192
|
+
new DataView(optLen.buffer).setUint32(0, c.optionsIpc.length, true);
|
|
193
|
+
const parts = [
|
|
194
|
+
head,
|
|
195
|
+
packText(c.ticketId, "ticket_id"),
|
|
196
|
+
packText(c.catalogName, "catalog_name"),
|
|
197
|
+
packText(c.dataVersionSpec, "data_version_spec"),
|
|
198
|
+
packText(c.implementationVersion, "implementation_version"),
|
|
199
|
+
optLen,
|
|
200
|
+
c.optionsIpc,
|
|
201
|
+
];
|
|
202
|
+
let total = 0;
|
|
203
|
+
for (const p of parts) total += p.length;
|
|
204
|
+
const out = new Uint8Array(total);
|
|
205
|
+
let off = 0;
|
|
206
|
+
for (const p of parts) {
|
|
207
|
+
out.set(p, off);
|
|
208
|
+
off += p.length;
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Parse strictly: exact lengths, valid UTF-8, field rules, no trailing bytes. */
|
|
214
|
+
function decodePayload(payload: Uint8Array): AttachTicketClaims {
|
|
215
|
+
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
216
|
+
let pos = 0;
|
|
217
|
+
const need = (n: number): void => {
|
|
218
|
+
if (pos + n > payload.length) throw new AttachTicketInvalidError("attach ticket payload is truncated");
|
|
219
|
+
};
|
|
220
|
+
const text = (): string => {
|
|
221
|
+
need(2);
|
|
222
|
+
const len = view.getUint16(pos, true);
|
|
223
|
+
pos += 2;
|
|
224
|
+
need(len);
|
|
225
|
+
const raw = payload.subarray(pos, pos + len);
|
|
226
|
+
pos += len;
|
|
227
|
+
try {
|
|
228
|
+
return STRICT_UTF8.decode(raw);
|
|
229
|
+
} catch {
|
|
230
|
+
throw new AttachTicketInvalidError("attach ticket payload is not UTF-8");
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
need(16);
|
|
234
|
+
const issuedAt = Number(view.getBigInt64(0, true));
|
|
235
|
+
const expiresAt = Number(view.getBigInt64(8, true));
|
|
236
|
+
pos = 16;
|
|
237
|
+
const ticketId = text();
|
|
238
|
+
const catalogName = text();
|
|
239
|
+
const dataVersionSpec = text();
|
|
240
|
+
const implementationVersion = text();
|
|
241
|
+
need(4);
|
|
242
|
+
const optionsLen = view.getUint32(pos, true);
|
|
243
|
+
pos += 4;
|
|
244
|
+
if (optionsLen > MAX_OPTIONS_BYTES) throw new AttachTicketInvalidError("attach ticket options exceed 16 KiB");
|
|
245
|
+
need(optionsLen);
|
|
246
|
+
const optionsIpc = payload.slice(pos, pos + optionsLen);
|
|
247
|
+
pos += optionsLen;
|
|
248
|
+
if (pos !== payload.length) throw new AttachTicketInvalidError("attach ticket payload has trailing bytes");
|
|
249
|
+
if (!TICKET_ID.test(ticketId)) throw new AttachTicketInvalidError("attach ticket id is not 32 lowercase hex");
|
|
250
|
+
if (!catalogName) throw new AttachTicketInvalidError("attach ticket names no catalog");
|
|
251
|
+
if (expiresAt !== 0 && expiresAt <= issuedAt) throw new AttachTicketInvalidError("attach ticket lifetime is empty");
|
|
252
|
+
return { issuedAt, expiresAt, ticketId, catalogName, dataVersionSpec, implementationVersion, optionsIpc };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function b64url(data: Uint8Array): string {
|
|
256
|
+
let bin = "";
|
|
257
|
+
for (let i = 0; i < data.length; i++) bin += String.fromCharCode(data[i]);
|
|
258
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Decode unpadded base64url, rejecting any spelling but the canonical one. */
|
|
262
|
+
function b64urlStrict(text: string): Uint8Array {
|
|
263
|
+
if (!B64URL.test(text) || text.length % 4 === 1) {
|
|
264
|
+
throw new AttachTicketInvalidError("attach ticket is not unpadded base64url");
|
|
265
|
+
}
|
|
266
|
+
const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (text.length % 4)) % 4);
|
|
267
|
+
const bin = atob(padded);
|
|
268
|
+
const raw = new Uint8Array(bin.length);
|
|
269
|
+
for (let i = 0; i < bin.length; i++) raw[i] = bin.charCodeAt(i);
|
|
270
|
+
if (b64url(raw) !== text) throw new AttachTicketInvalidError("attach ticket is not canonical base64url");
|
|
271
|
+
return raw;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function randomHex(bytes: number): string {
|
|
275
|
+
const b = crypto.getRandomValues(new Uint8Array(bytes));
|
|
276
|
+
let s = "";
|
|
277
|
+
for (const x of b) s += x.toString(16).padStart(2, "0");
|
|
278
|
+
return s;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Inputs to {@link mintAttachTicket}. */
|
|
282
|
+
export interface MintAttachTicketOptions {
|
|
283
|
+
principal: string;
|
|
284
|
+
catalogName: string;
|
|
285
|
+
optionsIpc: Uint8Array;
|
|
286
|
+
dataVersionSpec?: string;
|
|
287
|
+
implementationVersion?: string;
|
|
288
|
+
issuedAt: number;
|
|
289
|
+
expiresAt: number;
|
|
290
|
+
/** Override the random id -- for vectors only. */
|
|
291
|
+
ticketId?: string;
|
|
292
|
+
/** A fixed 24-byte nonce -- for vectors only. */
|
|
293
|
+
nonce?: Uint8Array;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Seal a ticket for `principal` under `signingKey` (the attach_opaque_data
|
|
298
|
+
* key; any length, normalized exactly as the attach envelope's is).
|
|
299
|
+
*
|
|
300
|
+
* @throws Error An empty principal or catalog, an empty lifetime, a bad
|
|
301
|
+
* ticket id, or a field too long to encode.
|
|
302
|
+
*/
|
|
303
|
+
export async function mintAttachTicket(
|
|
304
|
+
signingKey: Uint8Array,
|
|
305
|
+
opts: MintAttachTicketOptions,
|
|
306
|
+
): Promise<{ token: string; claims: AttachTicketClaims }> {
|
|
307
|
+
if (!opts.principal) throw new Error("a ticket needs a principal");
|
|
308
|
+
if (!opts.catalogName) throw new Error("a ticket needs a catalog name");
|
|
309
|
+
const claims: AttachTicketClaims = {
|
|
310
|
+
issuedAt: Math.trunc(opts.issuedAt),
|
|
311
|
+
expiresAt: Math.trunc(opts.expiresAt),
|
|
312
|
+
ticketId: opts.ticketId ?? randomHex(16),
|
|
313
|
+
catalogName: opts.catalogName,
|
|
314
|
+
dataVersionSpec: opts.dataVersionSpec ?? "",
|
|
315
|
+
implementationVersion: opts.implementationVersion ?? "",
|
|
316
|
+
optionsIpc: opts.optionsIpc,
|
|
317
|
+
};
|
|
318
|
+
if (!TICKET_ID.test(claims.ticketId)) throw new Error("ticket_id must be 32 lowercase hex");
|
|
319
|
+
if (claims.expiresAt !== 0 && claims.expiresAt <= claims.issuedAt) {
|
|
320
|
+
throw new Error("expires_at must be 0 or after issued_at");
|
|
321
|
+
}
|
|
322
|
+
const envelope = await sealBytes(
|
|
323
|
+
encodePayload(claims),
|
|
324
|
+
signingKey,
|
|
325
|
+
attachTicketAad(opts.principal),
|
|
326
|
+
TICKET_ENVELOPE_VERSION,
|
|
327
|
+
opts.nonce,
|
|
328
|
+
);
|
|
329
|
+
const token = ATTACH_TICKET_PREFIX + b64url(envelope);
|
|
330
|
+
if (token.length > MAX_TICKET_CHARS) {
|
|
331
|
+
throw new Error(`the ticket would be ${token.length} characters; at most ${MAX_TICKET_CHARS} are accepted`);
|
|
332
|
+
}
|
|
333
|
+
return { token, claims };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Verify a ticket for the calling `principal` and return what it carries.
|
|
338
|
+
*
|
|
339
|
+
* Order, normative: prefix, length, canonical base64url, caller, AEAD open
|
|
340
|
+
* under the caller's principal, strict payload parse, then lifetime with a
|
|
341
|
+
* 60 s skew. The lifetime is inside the ciphertext, so it is trusted only
|
|
342
|
+
* after the tag verified.
|
|
343
|
+
*
|
|
344
|
+
* @param now Override the clock (seconds), for tests and vectors.
|
|
345
|
+
* @throws AttachTicketInvalidError Any cause but expiry.
|
|
346
|
+
* @throws AttachTicketExpiredError Authentic but outside its lifetime.
|
|
347
|
+
*/
|
|
348
|
+
export async function openAttachTicket(
|
|
349
|
+
signingKey: Uint8Array,
|
|
350
|
+
token: string,
|
|
351
|
+
principal: string | null | undefined,
|
|
352
|
+
now?: number,
|
|
353
|
+
): Promise<AttachTicketClaims> {
|
|
354
|
+
if (!token.startsWith(ATTACH_TICKET_PREFIX)) throw new AttachTicketInvalidError("not an attach ticket");
|
|
355
|
+
if (token.length > MAX_TICKET_CHARS) throw new AttachTicketInvalidError("attach ticket is too long");
|
|
356
|
+
const envelope = b64urlStrict(token.slice(ATTACH_TICKET_PREFIX.length));
|
|
357
|
+
if (!principal) throw new AttachTicketInvalidError("an anonymous caller cannot redeem an attach ticket");
|
|
358
|
+
let payload: Uint8Array;
|
|
359
|
+
try {
|
|
360
|
+
payload = await openBytes(envelope, signingKey, attachTicketAad(principal), TICKET_ENVELOPE_VERSION);
|
|
361
|
+
} catch (e) {
|
|
362
|
+
if (e instanceof OpaqueDataRejectedError) {
|
|
363
|
+
throw new AttachTicketInvalidError("attach ticket failed verification");
|
|
364
|
+
}
|
|
365
|
+
throw e;
|
|
366
|
+
}
|
|
367
|
+
const claims = decodePayload(payload);
|
|
368
|
+
const current = now ?? Date.now() / 1000;
|
|
369
|
+
if (claims.issuedAt > current + CLOCK_SKEW_SECONDS) throw new AttachTicketExpiredError("attach ticket is not yet valid");
|
|
370
|
+
if (claims.expiresAt !== 0 && current >= claims.expiresAt + CLOCK_SKEW_SECONDS) {
|
|
371
|
+
throw new AttachTicketExpiredError("attach ticket has expired");
|
|
372
|
+
}
|
|
373
|
+
return claims;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// Redemption: what catalog_attach does with `vgi_attach_ticket`
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
/** The catalog_attach request fields a ticket replaces. */
|
|
381
|
+
export interface RestoredAttach {
|
|
382
|
+
name: string;
|
|
383
|
+
/** Raw Arrow IPC options bytes, or `null` for none. */
|
|
384
|
+
optionsIpc: Uint8Array | null;
|
|
385
|
+
/** The same options, decoded. */
|
|
386
|
+
options: Record<string, unknown>;
|
|
387
|
+
dataVersionSpec: string | null;
|
|
388
|
+
implementationVersion: string | null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The caller's principal, or `null` for anonymous. */
|
|
392
|
+
export function callerPrincipal(auth: AuthContext | undefined | null): string | null {
|
|
393
|
+
if (!auth || !auth.authenticated) return null;
|
|
394
|
+
return auth.principal || null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Replace a ticket-carrying attach with the attach it seals.
|
|
399
|
+
*
|
|
400
|
+
* Returns `null` when `options` carries no `vgi_attach_ticket` (the request is
|
|
401
|
+
* untouched). Otherwise the ticket must be the only option
|
|
402
|
+
* ({@link AttachTicketRequestError} -- checked before the ticket is opened),
|
|
403
|
+
* must open under the caller's principal and `signingKey`
|
|
404
|
+
* ({@link AttachTicketInvalidError}; no key, as off HTTP, never opens one),
|
|
405
|
+
* and must be within its lifetime ({@link AttachTicketExpiredError}).
|
|
406
|
+
* Never logs the ticket or a restored option.
|
|
407
|
+
*/
|
|
408
|
+
export async function redeemAttachTicket(
|
|
409
|
+
options: Record<string, unknown>,
|
|
410
|
+
signingKey: Uint8Array | undefined | null,
|
|
411
|
+
auth: AuthContext | undefined | null,
|
|
412
|
+
now?: number,
|
|
413
|
+
): Promise<RestoredAttach | null> {
|
|
414
|
+
const keys = Object.keys(options);
|
|
415
|
+
const ticketKeys = keys.filter((k) => k.toLowerCase() === ATTACH_TICKET_OPTION);
|
|
416
|
+
if (ticketKeys.length === 0) return null;
|
|
417
|
+
const others = keys.filter((k) => k !== ticketKeys[0]);
|
|
418
|
+
if (others.length > 0) {
|
|
419
|
+
throw new AttachTicketRequestError(
|
|
420
|
+
`${ATTACH_TICKET_OPTION} must be the only attach option`,
|
|
421
|
+
others.map((k) => [`options.${k}`, `not allowed alongside ${ATTACH_TICKET_OPTION}`] as const),
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const token = options[ticketKeys[0]];
|
|
425
|
+
if (typeof token !== "string") throw new AttachTicketInvalidError(`${ATTACH_TICKET_OPTION} must be a string`);
|
|
426
|
+
if (!signingKey) throw new AttachTicketInvalidError("this worker does not redeem attach tickets");
|
|
427
|
+
const claims = await openAttachTicket(signingKey, token, callerPrincipal(auth), now);
|
|
428
|
+
let restored: Record<string, unknown> = {};
|
|
429
|
+
if (claims.optionsIpc.length > 0) {
|
|
430
|
+
try {
|
|
431
|
+
restored = decodeOptionsBatch(claims.optionsIpc);
|
|
432
|
+
} catch {
|
|
433
|
+
throw new AttachTicketInvalidError("attach ticket options are not an Arrow IPC record");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
name: claims.catalogName,
|
|
438
|
+
optionsIpc: claims.optionsIpc.length > 0 ? claims.optionsIpc : null,
|
|
439
|
+
options: restored,
|
|
440
|
+
dataVersionSpec: claims.dataVersionSpec || null,
|
|
441
|
+
implementationVersion: claims.implementationVersion || null,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// vgi.attach_tickets.v1
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
/** `SealAttachRequest`, field for field with vgi-python. */
|
|
450
|
+
export const SEAL_ATTACH_REQUEST_SCHEMA = schema([
|
|
451
|
+
field("catalog_name", utf8(), false),
|
|
452
|
+
field("options", binary(), true),
|
|
453
|
+
field("data_version_spec", utf8(), false),
|
|
454
|
+
field("implementation_version", utf8(), false),
|
|
455
|
+
field("ttl_seconds", int64(), false),
|
|
456
|
+
]);
|
|
457
|
+
|
|
458
|
+
/** `AttachTicket`, field for field with vgi-python. */
|
|
459
|
+
export const ATTACH_TICKET_SCHEMA = schema([
|
|
460
|
+
field("ticket", utf8(), false),
|
|
461
|
+
field("expires_at", float64(), false),
|
|
462
|
+
]);
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The ticket lifetime ceiling: the worker's grant maximum, or `null`.
|
|
466
|
+
*
|
|
467
|
+
* With grant keys configured it is their `maxTtlSeconds`. Otherwise (a worker
|
|
468
|
+
* minting its own grants) `VGI_RPC_GRANT_MAX_TTL_SECONDS` when set, else none.
|
|
469
|
+
*
|
|
470
|
+
* @throws Error The environment value is not a positive integer.
|
|
471
|
+
*/
|
|
472
|
+
export function resolveTicketMaxTtl(
|
|
473
|
+
grantKeys: { readonly maxTtlSeconds: number } | null | undefined,
|
|
474
|
+
env: Record<string, string | undefined> | undefined,
|
|
475
|
+
): number | null {
|
|
476
|
+
if (grantKeys) return Math.trunc(grantKeys.maxTtlSeconds);
|
|
477
|
+
const raw = (env?.VGI_RPC_GRANT_MAX_TTL_SECONDS ?? "").trim();
|
|
478
|
+
if (!raw) return null;
|
|
479
|
+
const value = /^\d+$/.test(raw) ? Number(raw) : 0;
|
|
480
|
+
if (!(value > 0)) throw new Error(`VGI_RPC_GRANT_MAX_TTL_SECONDS=${JSON.stringify(raw)} must be a positive integer`);
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** What `seal_attach` needs from the worker. */
|
|
485
|
+
export interface AttachTicketsConfig {
|
|
486
|
+
/** The worker's signing key -- the key that seals attach_opaque_data. */
|
|
487
|
+
signingKey: Uint8Array;
|
|
488
|
+
/** The catalog(s) the worker serves; validation reads their declared attach options. */
|
|
489
|
+
catalogInterface: CatalogInterface | undefined;
|
|
490
|
+
/** Lifetime ceiling in seconds, or `null` for no maximum. */
|
|
491
|
+
maxTtlSeconds: number | null;
|
|
492
|
+
/** Clock override (seconds), for tests. */
|
|
493
|
+
now?: () => number;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The attach options `catalogName` declares, or `null` if no such catalog. */
|
|
497
|
+
async function declaredSpecs(
|
|
498
|
+
catalog: CatalogInterface | undefined,
|
|
499
|
+
catalogName: string,
|
|
500
|
+
): Promise<Array<{ name: string; required?: boolean }> | null> {
|
|
501
|
+
if (!catalog) return null;
|
|
502
|
+
const infos = catalog.catalogsInfo
|
|
503
|
+
? await catalog.catalogsInfo()
|
|
504
|
+
: catalog.catalogs().map((name) => ({ name, attach_option_specs: [] as Uint8Array[] }));
|
|
505
|
+
for (const info of infos) {
|
|
506
|
+
if (info.name === catalogName) {
|
|
507
|
+
return deserializeAttachOptionSpecs(
|
|
508
|
+
((info.attach_option_specs ?? []) as unknown[]).map((b) => toUint8Array(b as Uint8Array)),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Validate and seal; the body of `seal_attach`. */
|
|
516
|
+
export async function sealAttach(
|
|
517
|
+
config: AttachTicketsConfig,
|
|
518
|
+
request: Record<string, unknown>,
|
|
519
|
+
auth: AuthContext | undefined,
|
|
520
|
+
): Promise<{ ticket: string; expires_at: number }> {
|
|
521
|
+
const principal = callerPrincipal(auth);
|
|
522
|
+
if (principal === null) throw new SealAttachDeniedError("an anonymous caller cannot seal an attach ticket");
|
|
523
|
+
|
|
524
|
+
const violations: Array<readonly [string, string]> = [];
|
|
525
|
+
const ttl = Number(request.ttl_seconds ?? 0);
|
|
526
|
+
if (!Number.isFinite(ttl) || ttl < 0) violations.push(["ttl_seconds", "must be 0 (as long as allowed) or positive"]);
|
|
527
|
+
|
|
528
|
+
const catalogName = String(request.catalog_name ?? "");
|
|
529
|
+
let optionsIpc: Uint8Array = new Uint8Array(0);
|
|
530
|
+
let options: Record<string, unknown> = {};
|
|
531
|
+
if (request.options != null) {
|
|
532
|
+
const raw = toUint8Array(request.options as Uint8Array);
|
|
533
|
+
if (raw.length > 0) {
|
|
534
|
+
const batch = deserializeBatch(raw);
|
|
535
|
+
if (batch.numRows > 1) {
|
|
536
|
+
violations.push(["options", "must be a one-row record"]);
|
|
537
|
+
} else if (batch.numRows === 1) {
|
|
538
|
+
options = decodeOptionsBatch(raw);
|
|
539
|
+
optionsIpc = raw;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const specs = await declaredSpecs(config.catalogInterface, catalogName);
|
|
545
|
+
if (specs === null) {
|
|
546
|
+
violations.push(["catalog_name", `no catalog named ${JSON.stringify(catalogName)}`]);
|
|
547
|
+
} else {
|
|
548
|
+
const declared = new Set(specs.map((s) => s.name.toLowerCase()));
|
|
549
|
+
for (const name of Object.keys(options)) {
|
|
550
|
+
if (name.toLowerCase() === ATTACH_TICKET_OPTION) {
|
|
551
|
+
violations.push([`options.${name}`, "a ticket cannot seal another ticket"]);
|
|
552
|
+
} else if (!declared.has(name.toLowerCase())) {
|
|
553
|
+
violations.push([`options.${name}`, "not an attach option this catalog declares"]);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const supplied = new Set(Object.keys(options).map((n) => n.toLowerCase()));
|
|
557
|
+
for (const spec of specs) {
|
|
558
|
+
if (spec.required && !supplied.has(spec.name.toLowerCase())) violations.push([`options.${spec.name}`, "required"]);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (optionsIpc.length > MAX_OPTIONS_BYTES) {
|
|
562
|
+
violations.push(["options", `${optionsIpc.length} bytes; a ticket carries at most ${MAX_OPTIONS_BYTES}`]);
|
|
563
|
+
}
|
|
564
|
+
if (violations.length > 0) throw new AttachTicketRequestError("seal_attach request is invalid", violations);
|
|
565
|
+
|
|
566
|
+
const issuedAt = Math.trunc(config.now ? config.now() : Date.now() / 1000);
|
|
567
|
+
const ceiling = config.maxTtlSeconds;
|
|
568
|
+
// 0 asks for the ceiling; otherwise the request, capped at the ceiling.
|
|
569
|
+
const lifetime = ttl === 0 ? ceiling : ceiling === null ? Math.trunc(ttl) : Math.min(Math.trunc(ttl), ceiling);
|
|
570
|
+
const expiresAt = lifetime === null ? 0 : issuedAt + lifetime;
|
|
571
|
+
let token: string;
|
|
572
|
+
try {
|
|
573
|
+
({ token } = await mintAttachTicket(config.signingKey, {
|
|
574
|
+
principal,
|
|
575
|
+
catalogName,
|
|
576
|
+
optionsIpc,
|
|
577
|
+
dataVersionSpec: String(request.data_version_spec ?? ""),
|
|
578
|
+
implementationVersion: String(request.implementation_version ?? ""),
|
|
579
|
+
issuedAt,
|
|
580
|
+
expiresAt,
|
|
581
|
+
}));
|
|
582
|
+
} catch (e) {
|
|
583
|
+
throw new AttachTicketRequestError("seal_attach request is invalid", [["request", (e as Error).message]]);
|
|
584
|
+
}
|
|
585
|
+
return { ticket: token, expires_at: expiresAt === 0 ? Number.POSITIVE_INFINITY : expiresAt };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Build `vgi.attach_tickets.v1`: one unary method, `seal_attach(request:
|
|
590
|
+
* SealAttachRequest) -> AttachTicket`, each carried as serialized Arrow IPC
|
|
591
|
+
* in a single binary column -- the same wire, and protocol hash, as
|
|
592
|
+
* vgi-python's.
|
|
593
|
+
*/
|
|
594
|
+
export function buildAttachTicketsProtocol(config: AttachTicketsConfig): Protocol {
|
|
595
|
+
const p = new Protocol(ATTACH_TICKETS_PROTOCOL_NAME, { protocolVersion: ATTACH_TICKETS_PROTOCOL_VERSION });
|
|
596
|
+
p.unary("seal_attach", {
|
|
597
|
+
params: REQUEST_PARAMS_SCHEMA as never,
|
|
598
|
+
result: RESULT_BINARY_SCHEMA as never,
|
|
599
|
+
doc: "Seal the caller's attach of request.catalog_name into a ticket.",
|
|
600
|
+
handler: async (params: Record<string, unknown>, ctx: unknown) => {
|
|
601
|
+
const request = unwrapRequest(params.request);
|
|
602
|
+
const sealed = await sealAttach(config, request, (ctx as CallContext | undefined)?.auth);
|
|
603
|
+
const batch = batchFromColumns(
|
|
604
|
+
{ ticket: [sealed.ticket], expires_at: [sealed.expires_at] },
|
|
605
|
+
ATTACH_TICKET_SCHEMA,
|
|
606
|
+
);
|
|
607
|
+
return { result: serializeBatch(batch) };
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
return p;
|
|
611
|
+
}
|