@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.
@@ -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
+ }
@@ -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
- throw new Error(`CompositeCatalog: no backend at route-byte index ${idx} (have ${this._backends.length})`);
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
- const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
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, { transport: "http", serverId });
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 { type AttachOptionSpec, serializeAttachOptionSpec, serializeAttachOptionSpecs, deserializeAttachOptionSpec, deserializeAttachOptionSpecs } from "./catalog/attach-option.js";
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";
@@ -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
- const optionsDict = decodeOptionsBatch(innerParams.options);
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
- const identity = IDENTITY_TRANSPORTS.has(options.transport) ? buildIdentity(hosting, options.env) : undefined;
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
  *
@@ -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`);