@query-farm/vgi 0.39.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.
@@ -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";
@@ -318,6 +344,7 @@ export type { RpcClient, StreamSession, LogMessage } from "@query-farm/vgi-rpc";
318
344
  // GrantRefusedError to decline a grant.
319
345
  export {
320
346
  buildRpcServer,
347
+ grantKeysFromArgv,
321
348
  INTROSPECT_PRINCIPALS_ENV,
322
349
  type BuildRpcServerOptions,
323
350
  type HostingOptions,
@@ -325,6 +352,7 @@ export {
325
352
  } from "./rpc-server.js";
326
353
  export {
327
354
  AuthUnavailableError,
355
+ GrantKeys,
328
356
  GrantRefusedError,
329
357
  IdentityUnavailableError,
330
358
  StatusError,
@@ -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,
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
@@ -22,12 +25,15 @@
22
25
  // exactly as it would against a single-protocol server.
23
26
 
24
27
  import {
28
+ GrantKeys,
25
29
  type GrantMinter,
26
30
  IdentityImpl,
27
31
  type Protocol,
28
32
  type TokenResolver,
29
33
  VgiRpcServer,
30
34
  } from "@query-farm/vgi-rpc";
35
+ import { buildAttachTicketsProtocol, resolveTicketMaxTtl } from "./attach-ticket.js";
36
+ import type { CatalogInterface } from "./catalog/interface.js";
31
37
 
32
38
  /** The transport a server is being built for. Only `"http"` changes what is
33
39
  * hosted (identity); the rest are recorded so the decision lives here rather
@@ -117,6 +123,20 @@ export interface HostingOptions {
117
123
  /** Maximum age of the caller's authentication for `issue_grant`, in
118
124
  * seconds. vgi-rpc's default when omitted. */
119
125
  maxAuthAge?: number;
126
+
127
+ /**
128
+ * Sealed-grant keys (vgi-rpc `GrantKeys`), turning on the framework's own
129
+ * grants on HTTP: `issue_grant` mints `vgig1.` tokens (unless
130
+ * {@link mintGrant} is supplied) and the worker's HTTP authentication accepts
131
+ * them back as bearer credentials, as the grant's owner. When omitted,
132
+ * `VGI_RPC_GRANT_KEYS` (comma-separated base64, exactly 32 bytes each, first
133
+ * mints, all verify), `VGI_RPC_GRANT_AUDIENCE` and
134
+ * `VGI_RPC_GRANT_MAX_TTL_SECONDS` are read; `serveVgiWorker` also accepts
135
+ * `--grant-key KEY` (repeatable). Unset everywhere, nothing changes. A
136
+ * malformed key refuses to start. `null` turns grants off regardless of the
137
+ * environment.
138
+ */
139
+ grantKeys?: GrantKeys | null;
120
140
  }
121
141
 
122
142
  /** Server-level options passed through to `VgiRpcServer`. */
@@ -128,6 +148,19 @@ export interface BuildRpcServerOptions {
128
148
  /** Environment to read `VGI_INTROSPECT_PRINCIPALS` from. Default
129
149
  * `process.env` where it exists (not on Cloudflare Workers). */
130
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
+ };
131
164
  }
132
165
 
133
166
  /**
@@ -151,11 +184,23 @@ export function buildRpcServer(
151
184
  throw new Error(`Unknown transport ${JSON.stringify(options.transport)}; expected one of ${[...TRANSPORTS].join(", ")}.`);
152
185
  }
153
186
  const protocols = validatedHostedProtocols(hosting, primary);
154
- 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
+ }
155
197
  return new VgiRpcServer(primary, {
156
198
  serverId: options.serverId,
157
199
  protocols,
158
200
  identity,
201
+ // Resolved here, per transport, rather than by vgi-rpc's own environment
202
+ // default: identity (and so grants) is hosted on HTTP only.
203
+ grantKeys: null,
159
204
  });
160
205
  }
161
206
 
@@ -221,13 +266,16 @@ function validatedHostedProtocols(hosting: HostingOptions, primary: Protocol): P
221
266
  * hosting it and refusing every call. The two hooks are independent. */
222
267
  function buildIdentity(
223
268
  hosting: HostingOptions,
269
+ grantKeys: GrantKeys | null,
224
270
  env: Record<string, string | undefined> | undefined,
225
271
  ): IdentityImpl | undefined {
226
272
  const { resolveToken, mintGrant } = hosting;
227
- if (!resolveToken && !mintGrant) return undefined;
273
+ if (!resolveToken && !mintGrant && !grantKeys) return undefined;
228
274
  return new IdentityImpl({
229
275
  resolveToken,
276
+ // Absent with grant keys configured, vgi-rpc mints sealed grants itself.
230
277
  mintGrant,
278
+ grantKeys: grantKeys ?? undefined,
231
279
  // Only meaningful for `introspect_token`; a worker that mints but resolves
232
280
  // nothing is not an oracle and needs no allowlist.
233
281
  introspectPrincipals: resolveToken ? resolveIntrospectPrincipals(hosting.introspectPrincipals, env) : undefined,
@@ -235,6 +283,26 @@ function buildIdentity(
235
283
  });
236
284
  }
237
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
+
238
306
  /**
239
307
  * Resolve the introspector allowlist, or refuse to start.
240
308
  *
@@ -271,3 +339,34 @@ function resolveIntrospectPrincipals(
271
339
  }
272
340
  return principals;
273
341
  }
342
+
343
+ function processEnv(): Record<string, string | undefined> | undefined {
344
+ return (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;
345
+ }
346
+
347
+ /**
348
+ * Collect `--grant-key KEY` / `--grant-key=KEY` (repeatable, first mints) from
349
+ * an argv, as a `GrantKeys` -- or `undefined` when none was passed, so the
350
+ * environment still applies. Audience and lifetime come from
351
+ * `VGI_RPC_GRANT_AUDIENCE` / `VGI_RPC_GRANT_MAX_TTL_SECONDS`.
352
+ *
353
+ * @throws Error A malformed key: a worker refuses to start.
354
+ */
355
+ export function grantKeysFromArgv(
356
+ argv: readonly string[],
357
+ env: Record<string, string | undefined> | undefined = processEnv(),
358
+ ): GrantKeys | undefined {
359
+ const keys: string[] = [];
360
+ for (let i = 0; i < argv.length; i++) {
361
+ const a = argv[i];
362
+ if (a === "--grant-key") {
363
+ if (i + 1 >= argv.length) throw new Error("--grant-key requires a value");
364
+ keys.push(argv[++i]);
365
+ } else if (a.startsWith("--grant-key=")) {
366
+ keys.push(a.slice("--grant-key=".length));
367
+ }
368
+ }
369
+ if (keys.length === 0) return undefined;
370
+ const fromEnv = GrantKeys.fromEnv({ ...(env ?? {}), VGI_RPC_GRANT_KEYS: keys.join(",") });
371
+ return fromEnv ?? undefined;
372
+ }
@@ -29,10 +29,11 @@ import {
29
29
  observePeerIdentity,
30
30
  peerIdentityPrimary,
31
31
  type AuthenticateFn,
32
+ GrantKeys,
32
33
  type PeerResolutionOptions,
33
34
  } from "@query-farm/vgi-rpc";
34
35
  import { createVgiFetch } from "./http/fetch.js";
35
- import type { HostingOptions } from "./rpc-server.js";
36
+ import { grantKeysFromArgv, type HostingOptions } from "./rpc-server.js";
36
37
 
37
38
  /** Environment variables `serveVgiWorker` reads. Injectable for testing. */
38
39
  export interface ServeEnv {
@@ -40,6 +41,14 @@ export interface ServeEnv {
40
41
  VGI_SIGNING_KEY?: string;
41
42
  VGI_TOKEN_TTL?: string;
42
43
  CORS_ORIGINS?: string;
44
+ /** Sealed-grant keys: comma-separated base64, exactly 32 bytes each, first mints. */
45
+ VGI_RPC_GRANT_KEYS?: string;
46
+ /** Audience bound into sealed grants. */
47
+ VGI_RPC_GRANT_AUDIENCE?: string;
48
+ /** Sealed-grant lifetime ceiling, seconds (default 7 days). */
49
+ VGI_RPC_GRANT_MAX_TTL_SECONDS?: string;
50
+ /** Principals allowed to call `introspect_token`, comma-separated. */
51
+ VGI_INTROSPECT_PRINCIPALS?: string;
43
52
  }
44
53
 
45
54
  export const DEFAULT_PORT = 8787;
@@ -94,6 +103,10 @@ export interface ServeVgiWorkerOptions extends HostingOptions {
94
103
  quiet?: boolean;
95
104
  /** Environment source (default `process.env`). */
96
105
  env?: ServeEnv;
106
+ /** Command-line arguments to read `--grant-key KEY` (repeatable, first mints)
107
+ * from. Default `process.argv.slice(2)`. A key here takes precedence over
108
+ * `VGI_RPC_GRANT_KEYS`; an explicit {@link grantKeys} over both. */
109
+ argv?: readonly string[];
97
110
  /** Authenticates each request, returning the caller's `AuthContext`.
98
111
  * Omit for an anonymous worker (the default). */
99
112
  authenticate?: AuthenticateFn;
@@ -211,6 +224,9 @@ export function createVgiWorkerFetch(
211
224
  ): (req: Request) => Promise<Response> {
212
225
  const env = opts.env ?? (process.env as ServeEnv);
213
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();
214
230
  const tokenTtl = resolveTokenTtl(opts.tokenTtl, env);
215
231
  // `null` disables CORS and must stay `null` through to createVgiFetch, which
216
232
  // now reads a bare `undefined` as "default to *" — passing `undefined` here to
@@ -228,6 +244,7 @@ export function createVgiWorkerFetch(
228
244
  return createVgiFetch({
229
245
  protocol: { registry: opts.registry, catalogInterface: opts.catalogInterface },
230
246
  signingKey,
247
+ signingKeyConfigured,
231
248
  tokenTtl,
232
249
  prefix: opts.prefix ?? "",
233
250
  serverId: opts.serverId ?? opts.name,
@@ -247,6 +264,11 @@ export function createVgiWorkerFetch(
247
264
  mintGrant: opts.mintGrant,
248
265
  introspectPrincipals: opts.introspectPrincipals,
249
266
  maxAuthAge: opts.maxAuthAge,
267
+ grantKeys:
268
+ opts.grantKeys !== undefined
269
+ ? opts.grantKeys
270
+ : (grantKeysFromArgv(opts.argv ?? process.argv.slice(2), env as Record<string, string | undefined>) ??
271
+ GrantKeys.fromEnv(env as Record<string, string | undefined>)),
250
272
  });
251
273
  }
252
274
 
@@ -18,6 +18,7 @@ export type { VgiFetchOptions } from "./http/fetch.js";
18
18
  export type { HostingOptions } from "./rpc-server.js";
19
19
  export {
20
20
  AuthUnavailableError,
21
+ GrantKeys,
21
22
  GrantRefusedError,
22
23
  IdentityUnavailableError,
23
24
  StatusError,
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`);