@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.
- package/README.md +75 -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 +4 -3
- package/dist/index.js +1964 -1447
- package/dist/index.js.map +15 -14
- package/dist/rpc-server.d.ts +37 -1
- package/dist/serve-entry.d.ts +13 -1
- package/dist/serve-entry.js +556 -13
- package/dist/serve-entry.js.map +10 -8
- package/dist/worker-cf-entry.d.ts +1 -1
- package/dist/worker-cf-entry.js +584 -63
- package/dist/worker-cf-entry.js.map +12 -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 +29 -1
- package/src/protocol/handlers/catalog/admin.ts +21 -1
- package/src/rpc-server.ts +102 -3
- package/src/serve-entry.ts +23 -1
- package/src/worker-cf-entry.ts +1 -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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/serve-entry.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/worker-cf-entry.ts
CHANGED
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`);
|