@query-farm/vgi 0.40.0 → 0.42.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 +1969 -1446
- package/dist/index.js.map +17 -15
- package/dist/protocol/dispatch.d.ts +13 -0
- package/dist/protocol/handlers/unimplemented.d.ts +17 -0
- package/dist/rpc-server.d.ts +14 -0
- package/dist/serve-entry.js +560 -10
- package/dist/serve-entry.js.map +12 -9
- package/dist/worker-cf-entry.js +606 -62
- package/dist/worker-cf-entry.js.map +13 -11
- 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/dispatch.ts +16 -0
- package/src/protocol/handlers/catalog/admin.ts +21 -1
- package/src/protocol/handlers/unimplemented.ts +74 -0
- package/src/rpc-server.ts +50 -5
- package/src/serve-entry.ts +4 -0
- package/src/worker.ts +13 -0
|
@@ -108,6 +108,29 @@ export interface AttachOptionSpec {
|
|
|
108
108
|
secret?: boolean;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Reserved for attach tickets: the framework reads an ATTACH option with this
|
|
113
|
+
* name as a `vgi_attach_ticket` before any catalog code runs, so no catalog may
|
|
114
|
+
* declare an attach option called this, compared case-insensitively.
|
|
115
|
+
*/
|
|
116
|
+
export const RESERVED_ATTACH_OPTION = "vgi_attach_ticket";
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Throw if any spec uses the reserved {@link RESERVED_ATTACH_OPTION} name.
|
|
120
|
+
*
|
|
121
|
+
* @throws Error naming the offending option.
|
|
122
|
+
*/
|
|
123
|
+
export function assertNoReservedAttachOption(specs: Iterable<Pick<AttachOptionSpec, "name">>): void {
|
|
124
|
+
for (const spec of specs) {
|
|
125
|
+
if (spec.name.toLowerCase() === RESERVED_ATTACH_OPTION) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Attach option '${spec.name}' uses the reserved name '${RESERVED_ATTACH_OPTION}': the framework ` +
|
|
128
|
+
"reads it as an attach ticket before any catalog code runs. Rename the option.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
111
134
|
const SPEC_SCHEMA = makeSchema([
|
|
112
135
|
field("name", utf8(), false),
|
|
113
136
|
field("description", utf8(), false),
|
|
@@ -122,6 +145,7 @@ const SPEC_SCHEMA = makeSchema([
|
|
|
122
145
|
* (one IPC-serialized RecordBatch with a single row).
|
|
123
146
|
*/
|
|
124
147
|
export function serializeAttachOptionSpec(spec: AttachOptionSpec): Uint8Array {
|
|
148
|
+
assertNoReservedAttachOption([spec]);
|
|
125
149
|
// Mirrors AttachOptionSpec.__post_init__ on the Python side: an option that
|
|
126
150
|
// falls back to a value is always satisfiable without the caller, so the
|
|
127
151
|
// combination is a declaration bug rather than a runtime condition.
|
|
@@ -260,6 +284,8 @@ export function validateRequiredAttachOptions(
|
|
|
260
284
|
specs: Iterable<AttachOptionSpec>,
|
|
261
285
|
options: Record<string, unknown>,
|
|
262
286
|
): void {
|
|
287
|
+
specs = Array.from(specs);
|
|
288
|
+
assertNoReservedAttachOption(specs);
|
|
263
289
|
const supplied = new Set(Object.keys(options).map((key) => key.toLowerCase()));
|
|
264
290
|
const missing = Array.from(specs)
|
|
265
291
|
.filter((spec) => spec.required && !supplied.has(spec.name.toLowerCase()))
|
|
@@ -268,3 +294,52 @@ export function validateRequiredAttachOptions(
|
|
|
268
294
|
throw new MissingAttachOptionsError(catalogName, missing);
|
|
269
295
|
}
|
|
270
296
|
}
|
|
297
|
+
|
|
298
|
+
/** Raised when a catalog declares the reserved attach option name. */
|
|
299
|
+
export class ReservedAttachOptionError extends Error {
|
|
300
|
+
constructor(message: string) {
|
|
301
|
+
super(message);
|
|
302
|
+
this.name = "ReservedAttachOptionError";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Startup check: refuse a catalog that declares an attach option named
|
|
308
|
+
* {@link RESERVED_ATTACH_OPTION}, reading the specs it advertises through
|
|
309
|
+
* `catalogsInfo()`.
|
|
310
|
+
*
|
|
311
|
+
* TypeScript has no declaration-time hook for a plain spec object, so the
|
|
312
|
+
* worker entry points run this before serving. Only the reserved-name refusal
|
|
313
|
+
* propagates (as {@link ReservedAttachOptionError}); any other failure reading
|
|
314
|
+
* `catalogsInfo()` -- a catalog whose discovery needs I/O that is not up yet
|
|
315
|
+
* -- is left for discovery itself to report.
|
|
316
|
+
*/
|
|
317
|
+
export async function checkReservedAttachOptions(catalog: {
|
|
318
|
+
catalogsInfo?(): unknown;
|
|
319
|
+
} | undefined): Promise<void> {
|
|
320
|
+
if (!catalog?.catalogsInfo) return;
|
|
321
|
+
let infos: unknown;
|
|
322
|
+
try {
|
|
323
|
+
infos = await catalog.catalogsInfo();
|
|
324
|
+
} catch (e) {
|
|
325
|
+
if (e instanceof Error && e.message.includes(`reserved name '${RESERVED_ATTACH_OPTION}'`)) {
|
|
326
|
+
throw new ReservedAttachOptionError(e.message);
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
for (const info of (infos ?? []) as Array<{ attach_option_specs?: unknown[] | null }>) {
|
|
331
|
+
for (const raw of info.attach_option_specs ?? []) {
|
|
332
|
+
let name: string;
|
|
333
|
+
try {
|
|
334
|
+
name = deserializeAttachOptionSpec(toUint8Array(raw as Uint8Array)).name;
|
|
335
|
+
} catch {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
assertNoReservedAttachOption([{ name }]);
|
|
340
|
+
} catch (e) {
|
|
341
|
+
throw new ReservedAttachOptionError((e as Error).message);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
package/src/catalog/composite.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
ViewInfo,
|
|
25
25
|
} from "./interface.js";
|
|
26
26
|
import { CatalogInterface } from "./interface.js";
|
|
27
|
+
import { OpaqueDataRejectedError } from "../crypto.js";
|
|
27
28
|
|
|
28
29
|
function bufferEquals(a: AttachOpaqueData, b: AttachOpaqueData): boolean {
|
|
29
30
|
if (a.length !== b.length) return false;
|
|
@@ -52,7 +53,10 @@ export class CompositeCatalogInterface extends CatalogInterface {
|
|
|
52
53
|
const idx = attachOpaqueData[ROUTE_BYTE];
|
|
53
54
|
const route = this._backends[idx];
|
|
54
55
|
if (!route) {
|
|
55
|
-
|
|
56
|
+
// Routing is a check too (vgi-opaque-data-sealing.md rule 4): an
|
|
57
|
+
// unroutable value gets the uniform refusal, never a description of
|
|
58
|
+
// what this worker serves.
|
|
59
|
+
throw new OpaqueDataRejectedError("attach_opaque_data");
|
|
56
60
|
}
|
|
57
61
|
return route;
|
|
58
62
|
}
|
package/src/crypto.ts
CHANGED
|
@@ -44,7 +44,13 @@ const TRANSACTION_AAD_PREFIX = _UTF8.encode("vgi.transaction_opaque_data.v1\0");
|
|
|
44
44
|
* this single error so a probing caller cannot distinguish them.
|
|
45
45
|
*/
|
|
46
46
|
export class OpaqueDataRejectedError extends Error {
|
|
47
|
+
/** Canonical code (vgi-opaque-data-sealing.md rule 4): never `UNKNOWN`. */
|
|
48
|
+
readonly errorCode = "INVALID_ARGUMENT";
|
|
49
|
+
/** The classified kind a client branches on; identical for every cause. */
|
|
50
|
+
readonly errorKind = "opaque_data_not_recognized";
|
|
47
51
|
constructor(field = "opaque data") {
|
|
52
|
+
// Exactly `<field> not recognized`, no details: a probing caller learns
|
|
53
|
+
// nothing about which check failed.
|
|
48
54
|
super(`${field} not recognized`);
|
|
49
55
|
this.name = "OpaqueDataRejectedError";
|
|
50
56
|
}
|
|
@@ -108,8 +114,14 @@ export async function sealBytes(
|
|
|
108
114
|
key: Uint8Array,
|
|
109
115
|
aad: Uint8Array,
|
|
110
116
|
version: number,
|
|
117
|
+
fixedNonce?: Uint8Array,
|
|
111
118
|
): Promise<Uint8Array> {
|
|
112
|
-
|
|
119
|
+
// A fixed nonce exists for byte-exact test vectors only; every real seal
|
|
120
|
+
// draws 24 fresh random bytes.
|
|
121
|
+
if (fixedNonce !== undefined && fixedNonce.length !== NONCE_LEN) {
|
|
122
|
+
throw new Error(`nonce must be ${NONCE_LEN} bytes`);
|
|
123
|
+
}
|
|
124
|
+
const nonce = fixedNonce ?? crypto.getRandomValues(new Uint8Array(NONCE_LEN));
|
|
113
125
|
const ciphertext = xchacha20poly1305(await normalizeKey(key), nonce, aad).encrypt(payload);
|
|
114
126
|
const out = new Uint8Array(1 + NONCE_LEN + ciphertext.length);
|
|
115
127
|
out[0] = version & 0xff;
|
package/src/http/fetch.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { buildVgiProtocol, type ProtocolConfig } from "../protocol/dispatch.js";
|
|
|
27
27
|
import { createLandingRoutes, type LandingInfo } from "./landing.js";
|
|
28
28
|
import { buildRpcServer, type HostingOptions } from "../rpc-server.js";
|
|
29
29
|
import { setRequestAuthScope, type RequestAuthHolder } from "../request-auth.js";
|
|
30
|
+
import { checkReservedAttachOptions } from "../catalog/attach-option.js";
|
|
30
31
|
|
|
31
32
|
export interface VgiFetchOptions extends HostingOptions {
|
|
32
33
|
/** Wire-protocol config (registry + catalogInterface). */
|
|
@@ -35,6 +36,12 @@ export interface VgiFetchOptions extends HostingOptions {
|
|
|
35
36
|
* (e.g. derived from a Wrangler secret). Required because Workers don't
|
|
36
37
|
* preserve in-memory state across requests/instances. */
|
|
37
38
|
signingKey: Uint8Array;
|
|
39
|
+
/** Whether {@link signingKey} came from explicit configuration (a secret,
|
|
40
|
+
* `VGI_SIGNING_KEY`) rather than being generated for this process. Only a
|
|
41
|
+
* configured key hosts `vgi.attach_tickets.v1` (with the ability to issue
|
|
42
|
+
* grants): tickets sealed under a generated key would die on restart.
|
|
43
|
+
* Defaults to `true`, since this factory requires the key to be passed. */
|
|
44
|
+
signingKeyConfigured?: boolean;
|
|
38
45
|
/** State-token TTL in seconds (default 3600). */
|
|
39
46
|
tokenTtl?: number;
|
|
40
47
|
/** URL path prefix for VGI requests (default "/vgi"). Pass "" to mount at
|
|
@@ -203,7 +210,21 @@ export function createVgiFetch(opts: VgiFetchOptions): (req: Request) => Promise
|
|
|
203
210
|
// authenticates callers -- vgi_rpc.Identity.v1 when the worker opts in.
|
|
204
211
|
// Built at construction, so a worker that opts into introspection without an
|
|
205
212
|
// allowlist fails here rather than serving traffic.
|
|
206
|
-
const server = buildRpcServer(protocol, opts, {
|
|
213
|
+
const server = buildRpcServer(protocol, opts, {
|
|
214
|
+
transport: "http",
|
|
215
|
+
serverId,
|
|
216
|
+
attachTickets: {
|
|
217
|
+
signingKey: opts.signingKey,
|
|
218
|
+
signingKeyConfigured: opts.signingKeyConfigured ?? true,
|
|
219
|
+
catalogInterface: opts.protocol.catalogInterface,
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
// A catalog declaring the reserved `vgi_attach_ticket` attach option must
|
|
223
|
+
// not serve. This factory is synchronous, so the check runs once up front
|
|
224
|
+
// and every request waits on it: a refusal fails each request (and is
|
|
225
|
+
// logged once) rather than letting a ticket be read as an ordinary option.
|
|
226
|
+
const reservedCheck = checkReservedAttachOptions(opts.protocol.catalogInterface);
|
|
227
|
+
reservedCheck.catch((e) => console.error(`VGI worker refused to serve: ${(e as Error).message}`));
|
|
207
228
|
|
|
208
229
|
const handler = createHttpHandler(server, {
|
|
209
230
|
prefix,
|
|
@@ -230,6 +251,7 @@ export function createVgiFetch(opts: VgiFetchOptions): (req: Request) => Promise
|
|
|
230
251
|
enableLandingPage: false,
|
|
231
252
|
});
|
|
232
253
|
return async (req: Request) => {
|
|
254
|
+
await reservedCheck;
|
|
233
255
|
const storage = await ensureRequestAuthStorage();
|
|
234
256
|
// No AsyncLocalStorage on this host: serve exactly as before.
|
|
235
257
|
if (!storage) return handler(req);
|
package/src/index.core.ts
CHANGED
|
@@ -166,7 +166,33 @@ export { createLandingRoutes } from "./http/landing.js";
|
|
|
166
166
|
export { BindResultSchema, ScanFunctionResultSchema } from "./generated/vgi-protocol-schemas.js";
|
|
167
167
|
|
|
168
168
|
// Attach-option specs (for catalogs that advertise typed ATTACH options).
|
|
169
|
-
export {
|
|
169
|
+
export {
|
|
170
|
+
type AttachOptionSpec,
|
|
171
|
+
serializeAttachOptionSpec,
|
|
172
|
+
serializeAttachOptionSpecs,
|
|
173
|
+
deserializeAttachOptionSpec,
|
|
174
|
+
deserializeAttachOptionSpecs,
|
|
175
|
+
RESERVED_ATTACH_OPTION,
|
|
176
|
+
ReservedAttachOptionError,
|
|
177
|
+
} from "./catalog/attach-option.js";
|
|
178
|
+
export {
|
|
179
|
+
ATTACH_TICKET_OPTION,
|
|
180
|
+
ATTACH_TICKET_PREFIX,
|
|
181
|
+
ATTACH_TICKETS_PROTOCOL_NAME,
|
|
182
|
+
ATTACH_TICKETS_PROTOCOL_VERSION,
|
|
183
|
+
ATTACH_TICKET_SCHEMA,
|
|
184
|
+
SEAL_ATTACH_REQUEST_SCHEMA,
|
|
185
|
+
AttachTicketExpiredError,
|
|
186
|
+
AttachTicketInvalidError,
|
|
187
|
+
AttachTicketRequestError,
|
|
188
|
+
SealAttachDeniedError,
|
|
189
|
+
type AttachTicketClaims,
|
|
190
|
+
type RestoredAttach,
|
|
191
|
+
attachTicketAad,
|
|
192
|
+
mintAttachTicket,
|
|
193
|
+
openAttachTicket,
|
|
194
|
+
redeemAttachTicket,
|
|
195
|
+
} from "./attach-ticket.js";
|
|
170
196
|
|
|
171
197
|
// Column statistics (for inlining on TableInfo.column_statistics).
|
|
172
198
|
export { type ColumnStatistics, serializeColumnStatistics } from "./util/statistics.js";
|
package/src/protocol/dispatch.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { registerFunctionMethods } from "./handlers/function.js";
|
|
|
15
15
|
import { registerAggregateMethods } from "./handlers/aggregate.js";
|
|
16
16
|
import { registerTableBufferingMethods } from "./handlers/table-buffering.js";
|
|
17
17
|
import { registerCatalogMethods } from "./handlers/catalog/index.js";
|
|
18
|
+
import { registerUnimplementedMethods } from "./handlers/unimplemented.js";
|
|
18
19
|
|
|
19
20
|
export interface ProtocolConfig {
|
|
20
21
|
registry: FunctionRegistry;
|
|
@@ -56,6 +57,20 @@ export interface ProtocolConfig {
|
|
|
56
57
|
*/
|
|
57
58
|
export const VGI_PROTOCOL_NAME = "vgi.v2";
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The `vgi.v2` protocol hash `vgi_rpc.Reflection.v1` must report for this
|
|
62
|
+
* worker: the reference's (vgi-python 0.43.0, 72 methods). Every SDK hosts
|
|
63
|
+
* every `vgi.v2` method with the reference's schemas, so the hash is one value
|
|
64
|
+
* across the ports; a method this SDK does not implement is still registered
|
|
65
|
+
* (handlers/unimplemented.ts). The hash covers method names, types and the
|
|
66
|
+
* params/result/header schemas only (WIRE_PROTOCOL.md §14).
|
|
67
|
+
*
|
|
68
|
+
* It changes only with vgi.v2's protocol version (currently 2.1.0). Asserted
|
|
69
|
+
* by src/protocol/__tests__/vgi-v2-hash.test.ts; if that test fails, the
|
|
70
|
+
* surface drifted from the reference -- fix the schemas, do not bump this.
|
|
71
|
+
*/
|
|
72
|
+
export const VGI_V2_PROTOCOL_HASH = "774cb80090d71ea76d09aa311b9cda4ca4c33c3bf72c43242eb6dc87b6f79ce5";
|
|
73
|
+
|
|
59
74
|
export function buildVgiProtocol(config: ProtocolConfig): Protocol {
|
|
60
75
|
const protocol = new Protocol(VGI_PROTOCOL_NAME, { protocolVersion: "2.1.0" });
|
|
61
76
|
|
|
@@ -67,6 +82,7 @@ export function buildVgiProtocol(config: ProtocolConfig): Protocol {
|
|
|
67
82
|
registerAggregateMethods(protocol, config.registry);
|
|
68
83
|
registerTableBufferingMethods(protocol, config.registry, config.signingKey);
|
|
69
84
|
registerCatalogMethods(protocol, config.catalogInterface, config.catalogName, config.signingKey);
|
|
85
|
+
registerUnimplementedMethods(protocol);
|
|
70
86
|
|
|
71
87
|
return protocol;
|
|
72
88
|
}
|
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
CatalogVersionResultSchema,
|
|
46
46
|
} from "../../../generated/vgi-protocol-schemas.js";
|
|
47
47
|
import { toUint8Array } from "../../../util/bytes.js";
|
|
48
|
+
import { redeemAttachTicket } from "../../../attach-ticket.js";
|
|
49
|
+
import { currentRequestAuth } from "../../../request-auth.js";
|
|
48
50
|
import { decodeDictValue } from "../../../util/arrow/index.js";
|
|
49
51
|
import {
|
|
50
52
|
REQUEST_PARAMS_SCHEMA,
|
|
@@ -98,7 +100,25 @@ export function registerCatalogAdminMethods(protocol: Protocol, getCatalog: GetC
|
|
|
98
100
|
// RecordBatch of typed columns — one column per option. Deserialize
|
|
99
101
|
// once here so workers see an ergonomic {name: value} dict instead of
|
|
100
102
|
// raw bytes. Nullable / absent → {}.
|
|
101
|
-
|
|
103
|
+
let optionsDict = decodeOptionsBatch(innerParams.options);
|
|
104
|
+
// A `vgi_attach_ticket` is redeemed here, before any catalog code runs:
|
|
105
|
+
// the request becomes the attach the ticket seals (catalog name,
|
|
106
|
+
// options, version specs). A composite catalog therefore routes on the
|
|
107
|
+
// sealed name, not the request's. The ticket opens only under the
|
|
108
|
+
// caller's principal and this worker's signing key; neither it nor a
|
|
109
|
+
// restored option is ever logged.
|
|
110
|
+
const restored = await redeemAttachTicket(
|
|
111
|
+
optionsDict,
|
|
112
|
+
signingKey,
|
|
113
|
+
(ctx as CallContext | undefined)?.auth ?? currentRequestAuth(),
|
|
114
|
+
);
|
|
115
|
+
if (restored !== null) {
|
|
116
|
+
innerParams.name = restored.name;
|
|
117
|
+
innerParams.options = restored.optionsIpc;
|
|
118
|
+
innerParams.data_version_spec = restored.dataVersionSpec;
|
|
119
|
+
innerParams.implementation_version = restored.implementationVersion;
|
|
120
|
+
optionsDict = restored.options;
|
|
121
|
+
}
|
|
102
122
|
const result = await cat.attach(
|
|
103
123
|
innerParams.name,
|
|
104
124
|
optionsDict,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
// vgi.v2 methods this SDK registers but does not implement.
|
|
3
|
+
//
|
|
4
|
+
// "The protocol is the unit of optionality": every SDK hosts EVERY vgi.v2
|
|
5
|
+
// method with exactly the reference's schemas, so vgi_rpc.Reflection.v1
|
|
6
|
+
// reports one vgi.v2 protocol hash everywhere (see VGI_V2_PROTOCOL_HASH in
|
|
7
|
+
// ../dispatch.ts). A method this port has no implementation for is still
|
|
8
|
+
// registered -- with the reference's params/result schemas -- and answers
|
|
9
|
+
// every call with vgi-rpc's MethodNotImplementedError: error_code
|
|
10
|
+
// UNIMPLEMENTED, error_kind method_not_implemented. Never a silent success.
|
|
11
|
+
//
|
|
12
|
+
// When one of these gains a real implementation, move it out of this list
|
|
13
|
+
// into its handler module; the schemas must not change (the hash test fails
|
|
14
|
+
// if they do).
|
|
15
|
+
|
|
16
|
+
import { MethodNotImplementedError, type Protocol } from "@query-farm/vgi-rpc";
|
|
17
|
+
import type { VgiSchema } from "../../arrow/index.js";
|
|
18
|
+
import {
|
|
19
|
+
CatalogIndexCreateParamsSchema,
|
|
20
|
+
CatalogIndexDropParamsSchema,
|
|
21
|
+
CatalogTableColumnCommentSetParamsSchema,
|
|
22
|
+
CatalogTableDeleteFunctionGetParamsSchema,
|
|
23
|
+
CatalogTableInsertFunctionGetParamsSchema,
|
|
24
|
+
CatalogTableUpdateFunctionGetParamsSchema,
|
|
25
|
+
} from "../../generated/vgi-protocol-schemas.js";
|
|
26
|
+
import { REQUEST_PARAMS_SCHEMA, RESULT_BINARY_SCHEMA } from "./shared.js";
|
|
27
|
+
import { emptyResultSchema } from "./catalog/shared.js";
|
|
28
|
+
|
|
29
|
+
/** One unimplemented method: its wire name and the reference's schemas. */
|
|
30
|
+
interface UnimplementedMethod {
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly params: VgiSchema;
|
|
33
|
+
readonly result: VgiSchema;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The vgi.v2 methods (of the reference's 72) this SDK does not implement.
|
|
38
|
+
* Exported so a test can assert each one refuses with UNIMPLEMENTED.
|
|
39
|
+
*/
|
|
40
|
+
export const UNIMPLEMENTED_METHODS: readonly UnimplementedMethod[] = [
|
|
41
|
+
// Streaming and window aggregates: request/result are wrapped ASD blobs.
|
|
42
|
+
{ name: "aggregate_streaming_open", params: REQUEST_PARAMS_SCHEMA, result: RESULT_BINARY_SCHEMA },
|
|
43
|
+
{ name: "aggregate_streaming_chunk", params: REQUEST_PARAMS_SCHEMA, result: RESULT_BINARY_SCHEMA },
|
|
44
|
+
{ name: "aggregate_streaming_close", params: REQUEST_PARAMS_SCHEMA, result: RESULT_BINARY_SCHEMA },
|
|
45
|
+
{ name: "aggregate_window_init", params: REQUEST_PARAMS_SCHEMA, result: RESULT_BINARY_SCHEMA },
|
|
46
|
+
{ name: "aggregate_window", params: REQUEST_PARAMS_SCHEMA, result: RESULT_BINARY_SCHEMA },
|
|
47
|
+
{ name: "aggregate_window_batch", params: REQUEST_PARAMS_SCHEMA, result: RESULT_BINARY_SCHEMA },
|
|
48
|
+
{ name: "aggregate_window_destructor", params: REQUEST_PARAMS_SCHEMA, result: RESULT_BINARY_SCHEMA },
|
|
49
|
+
// Index DDL and per-column comments.
|
|
50
|
+
{ name: "catalog_index_create", params: CatalogIndexCreateParamsSchema, result: emptyResultSchema },
|
|
51
|
+
{ name: "catalog_index_drop", params: CatalogIndexDropParamsSchema, result: emptyResultSchema },
|
|
52
|
+
{ name: "catalog_table_column_comment_set", params: CatalogTableColumnCommentSetParamsSchema, result: emptyResultSchema },
|
|
53
|
+
// Per-table DML function getters.
|
|
54
|
+
{ name: "catalog_table_insert_function_get", params: CatalogTableInsertFunctionGetParamsSchema, result: RESULT_BINARY_SCHEMA },
|
|
55
|
+
{ name: "catalog_table_update_function_get", params: CatalogTableUpdateFunctionGetParamsSchema, result: RESULT_BINARY_SCHEMA },
|
|
56
|
+
{ name: "catalog_table_delete_function_get", params: CatalogTableDeleteFunctionGetParamsSchema, result: RESULT_BINARY_SCHEMA },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** The message every unimplemented method answers with. */
|
|
60
|
+
export function unimplementedMessage(method: string): string {
|
|
61
|
+
return `${method} is not implemented by this worker`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function registerUnimplementedMethods(protocol: Protocol): void {
|
|
65
|
+
for (const m of UNIMPLEMENTED_METHODS) {
|
|
66
|
+
protocol.unary(m.name, {
|
|
67
|
+
params: m.params,
|
|
68
|
+
result: m.result,
|
|
69
|
+
handler: async () => {
|
|
70
|
+
throw new MethodNotImplementedError(unimplementedMessage(m.name));
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/rpc-server.ts
CHANGED
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
// transport.
|
|
15
15
|
// 3. `vgi_rpc.Reflection.v1` -- on every transport.
|
|
16
16
|
// 4. `vgi_rpc.Identity.v1` -- HTTP only, and only when the worker supplies
|
|
17
|
-
// `resolveToken` and/or `mintGrant
|
|
17
|
+
// `resolveToken` and/or `mintGrant` (or grant keys are configured).
|
|
18
|
+
// 5. `vgi.attach_tickets.v1` -- HTTP only, and only when the signing key was
|
|
19
|
+
// configured explicitly (never a generated one) *and* the worker can
|
|
20
|
+
// issue grants. See `attach-ticket.ts`.
|
|
18
21
|
//
|
|
19
22
|
// Extra protocols cannot change `vgi.v2` behaviour: vgi-rpc routes every
|
|
20
23
|
// request on its `vgi_rpc.protocol` key with no fallback to the primary, so a
|
|
@@ -29,6 +32,8 @@ import {
|
|
|
29
32
|
type TokenResolver,
|
|
30
33
|
VgiRpcServer,
|
|
31
34
|
} from "@query-farm/vgi-rpc";
|
|
35
|
+
import { buildAttachTicketsProtocol, resolveTicketMaxTtl } from "./attach-ticket.js";
|
|
36
|
+
import type { CatalogInterface } from "./catalog/interface.js";
|
|
32
37
|
|
|
33
38
|
/** The transport a server is being built for. Only `"http"` changes what is
|
|
34
39
|
* hosted (identity); the rest are recorded so the decision lives here rather
|
|
@@ -143,6 +148,19 @@ export interface BuildRpcServerOptions {
|
|
|
143
148
|
/** Environment to read `VGI_INTROSPECT_PRINCIPALS` from. Default
|
|
144
149
|
* `process.env` where it exists (not on Cloudflare Workers). */
|
|
145
150
|
env?: Record<string, string | undefined>;
|
|
151
|
+
/**
|
|
152
|
+
* HTTP only: what `vgi.attach_tickets.v1` needs. Hosted when
|
|
153
|
+
* `signingKeyConfigured` is true -- the key came from explicit
|
|
154
|
+
* configuration, not generated for this process, which would make every
|
|
155
|
+
* ticket die on restart -- and the worker can issue grants (grant keys
|
|
156
|
+
* configured, or its own `mintGrant`). Otherwise the protocol is absent,
|
|
157
|
+
* not hosted-and-refusing.
|
|
158
|
+
*/
|
|
159
|
+
attachTickets?: {
|
|
160
|
+
signingKey: Uint8Array | undefined;
|
|
161
|
+
signingKeyConfigured: boolean;
|
|
162
|
+
catalogInterface: CatalogInterface | undefined;
|
|
163
|
+
};
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
/**
|
|
@@ -166,7 +184,16 @@ export function buildRpcServer(
|
|
|
166
184
|
throw new Error(`Unknown transport ${JSON.stringify(options.transport)}; expected one of ${[...TRANSPORTS].join(", ")}.`);
|
|
167
185
|
}
|
|
168
186
|
const protocols = validatedHostedProtocols(hosting, primary);
|
|
169
|
-
|
|
187
|
+
let identity: IdentityImpl | undefined;
|
|
188
|
+
if (IDENTITY_TRANSPORTS.has(options.transport)) {
|
|
189
|
+
const env = options.env ?? processEnv();
|
|
190
|
+
// Explicit option, else the environment. Read at server build, so a
|
|
191
|
+
// malformed key stops the worker before it serves anything.
|
|
192
|
+
const grantKeys = hosting.grantKeys === undefined ? GrantKeys.fromEnv(env) : hosting.grantKeys;
|
|
193
|
+
identity = buildIdentity(hosting, grantKeys, options.env);
|
|
194
|
+
const tickets = buildAttachTickets(hosting, grantKeys, options.attachTickets, env);
|
|
195
|
+
if (tickets) protocols.push(tickets);
|
|
196
|
+
}
|
|
170
197
|
return new VgiRpcServer(primary, {
|
|
171
198
|
serverId: options.serverId,
|
|
172
199
|
protocols,
|
|
@@ -239,12 +266,10 @@ function validatedHostedProtocols(hosting: HostingOptions, primary: Protocol): P
|
|
|
239
266
|
* hosting it and refusing every call. The two hooks are independent. */
|
|
240
267
|
function buildIdentity(
|
|
241
268
|
hosting: HostingOptions,
|
|
269
|
+
grantKeys: GrantKeys | null,
|
|
242
270
|
env: Record<string, string | undefined> | undefined,
|
|
243
271
|
): IdentityImpl | undefined {
|
|
244
272
|
const { resolveToken, mintGrant } = hosting;
|
|
245
|
-
// Explicit option, else the environment. Read at server build, so a
|
|
246
|
-
// malformed key stops the worker before it serves anything.
|
|
247
|
-
const grantKeys = hosting.grantKeys === undefined ? GrantKeys.fromEnv(env ?? processEnv()) : hosting.grantKeys;
|
|
248
273
|
if (!resolveToken && !mintGrant && !grantKeys) return undefined;
|
|
249
274
|
return new IdentityImpl({
|
|
250
275
|
resolveToken,
|
|
@@ -258,6 +283,26 @@ function buildIdentity(
|
|
|
258
283
|
});
|
|
259
284
|
}
|
|
260
285
|
|
|
286
|
+
/** Build `vgi.attach_tickets.v1`, or `undefined` when it must not be hosted.
|
|
287
|
+
*
|
|
288
|
+
* Both halves of an unattended session have to be issuable: a ticket under a
|
|
289
|
+
* generated per-process key would die on restart, and a ticket without a
|
|
290
|
+
* grant attaches nothing. */
|
|
291
|
+
function buildAttachTickets(
|
|
292
|
+
hosting: HostingOptions,
|
|
293
|
+
grantKeys: GrantKeys | null,
|
|
294
|
+
config: BuildRpcServerOptions["attachTickets"],
|
|
295
|
+
env: Record<string, string | undefined> | undefined,
|
|
296
|
+
): Protocol | undefined {
|
|
297
|
+
if (!config || !config.signingKeyConfigured || !config.signingKey) return undefined;
|
|
298
|
+
if (!grantKeys && !hosting.mintGrant) return undefined;
|
|
299
|
+
return buildAttachTicketsProtocol({
|
|
300
|
+
signingKey: config.signingKey,
|
|
301
|
+
catalogInterface: config.catalogInterface,
|
|
302
|
+
maxTtlSeconds: resolveTicketMaxTtl(grantKeys, env),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
261
306
|
/**
|
|
262
307
|
* Resolve the introspector allowlist, or refuse to start.
|
|
263
308
|
*
|
package/src/serve-entry.ts
CHANGED
|
@@ -224,6 +224,9 @@ export function createVgiWorkerFetch(
|
|
|
224
224
|
): (req: Request) => Promise<Response> {
|
|
225
225
|
const env = opts.env ?? (process.env as ServeEnv);
|
|
226
226
|
const signingKey = resolveSigningKey(opts.signingKey, env);
|
|
227
|
+
// Explicit option or `$VGI_SIGNING_KEY`: a key that survives a restart.
|
|
228
|
+
// The generated fallback never enables attach tickets.
|
|
229
|
+
const signingKeyConfigured = opts.signingKey !== undefined || !!env.VGI_SIGNING_KEY?.trim();
|
|
227
230
|
const tokenTtl = resolveTokenTtl(opts.tokenTtl, env);
|
|
228
231
|
// `null` disables CORS and must stay `null` through to createVgiFetch, which
|
|
229
232
|
// now reads a bare `undefined` as "default to *" — passing `undefined` here to
|
|
@@ -241,6 +244,7 @@ export function createVgiWorkerFetch(
|
|
|
241
244
|
return createVgiFetch({
|
|
242
245
|
protocol: { registry: opts.registry, catalogInterface: opts.catalogInterface },
|
|
243
246
|
signingKey,
|
|
247
|
+
signingKeyConfigured,
|
|
244
248
|
tokenTtl,
|
|
245
249
|
prefix: opts.prefix ?? "",
|
|
246
250
|
serverId: opts.serverId ?? opts.name,
|
package/src/worker.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { Protocol } from "@query-farm/vgi-rpc";
|
|
|
17
17
|
import { schemaDescriptorPath, type CatalogDescriptor } from "./catalog/descriptors.js";
|
|
18
18
|
import type { CatalogInterface } from "./catalog/interface.js";
|
|
19
19
|
import { ReadOnlyCatalogInterface } from "./catalog/read-only.js";
|
|
20
|
+
import { checkReservedAttachOptions } from "./catalog/attach-option.js";
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Startup/lifecycle tracing, off unless `VGI_DEBUG` is set to something other
|
|
@@ -180,6 +181,18 @@ export class Worker {
|
|
|
180
181
|
|
|
181
182
|
run(argv: readonly string[] = process.argv.slice(2)): void {
|
|
182
183
|
debug(() => `[worker] starting, pid=${process.pid}`);
|
|
184
|
+
// Refuse to serve a catalog that declares the reserved `vgi_attach_ticket`
|
|
185
|
+
// attach option, before anything is read off the transport.
|
|
186
|
+
checkReservedAttachOptions(this._catalogInterface).then(
|
|
187
|
+
() => this._serve(argv),
|
|
188
|
+
(err: Error) => {
|
|
189
|
+
process.stderr.write(`Worker init error: ${err.message}\n`);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private _serve(argv: readonly string[]): void {
|
|
183
196
|
try {
|
|
184
197
|
const protocol = this.buildProtocol();
|
|
185
198
|
debug(() => `[worker] protocol built`);
|