@query-farm/vgi 0.28.0 → 0.29.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 +26 -7
- package/dist/arguments/argument-spec.d.ts +53 -0
- package/dist/arrow/impl-arrowjs/index.d.ts +1 -1
- package/dist/arrow/impl-arrowjs/iterate.d.ts +10 -1
- package/dist/arrow/impl-arrowjs/schema.d.ts +2 -0
- package/dist/arrow/impl-flechette/index.d.ts +1 -1
- package/dist/arrow/impl-flechette/iterate.d.ts +10 -1
- package/dist/arrow/impl-flechette/schema.d.ts +2 -0
- package/dist/arrow/index.d.ts +1 -1
- package/dist/arrow/schema-types.d.ts +3 -1
- package/dist/arrow/types.d.ts +39 -3
- package/dist/catalog/interface.d.ts +43 -0
- package/dist/client/client.d.ts +1 -1
- package/dist/client-entry.js +360 -178
- package/dist/client-entry.js.map +14 -14
- package/dist/functions/table.d.ts +63 -0
- package/dist/functions/types.d.ts +38 -0
- package/dist/generated/vgi-client.d.ts +59 -0
- package/dist/generated/vgi-protocol-schemas.d.ts +19 -0
- package/dist/http/fetch.d.ts +10 -0
- package/dist/index.core.d.ts +2 -2
- package/dist/index.js +1203 -834
- package/dist/index.js.map +40 -37
- package/dist/protocol/handlers/catalog/shared.d.ts +0 -8
- package/dist/protocol/handlers/function.d.ts +11 -0
- package/dist/protocol/serializers/bind.d.ts +1 -2
- package/dist/protocol/serializers/cardinality.d.ts +1 -2
- package/dist/protocol/serializers/splits.d.ts +126 -0
- package/dist/protocol/types.d.ts +27 -0
- package/dist/request-auth.d.ts +28 -0
- package/dist/serve-entry.d.ts +4 -0
- package/dist/serve-entry.js +745 -359
- package/dist/serve-entry.js.map +32 -29
- package/dist/split-token.d.ts +88 -0
- package/dist/worker-cf-entry.js +1029 -619
- package/dist/worker-cf-entry.js.map +37 -34
- package/package.json +3 -3
- package/src/arguments/argument-spec.ts +81 -0
- package/src/arguments/arguments.ts +0 -1
- package/src/arrow/impl-arrowjs/index.ts +1 -1
- package/src/arrow/impl-arrowjs/iterate.ts +21 -0
- package/src/arrow/impl-arrowjs/schema.ts +4 -0
- package/src/arrow/impl-flechette/index.ts +1 -1
- package/src/arrow/impl-flechette/iterate.ts +24 -1
- package/src/arrow/impl-flechette/schema.ts +4 -0
- package/src/arrow/index.ts +1 -1
- package/src/arrow/schema-types.ts +4 -0
- package/src/arrow/types.ts +39 -3
- package/src/catalog/interface.ts +124 -29
- package/src/catalog/read-only.ts +18 -18
- package/src/client/client.ts +6 -2
- package/src/functions/aggregate.ts +3 -0
- package/src/functions/copy-from.ts +2 -1
- package/src/functions/copy-to.ts +2 -1
- package/src/functions/scalar.ts +11 -0
- package/src/functions/table-buffering.ts +3 -0
- package/src/functions/table-in-out.ts +8 -4
- package/src/functions/table.ts +129 -2
- package/src/functions/types.ts +40 -0
- package/src/generated/vgi-client.ts +63 -1
- package/src/generated/vgi-protocol-schemas.ts +240 -14
- package/src/http/fetch.ts +117 -2
- package/src/index.core.ts +2 -2
- package/src/protocol/handlers/aggregate.ts +10 -5
- package/src/protocol/handlers/catalog/admin.ts +41 -48
- package/src/protocol/handlers/catalog/index_methods.ts +4 -11
- package/src/protocol/handlers/catalog/macro.ts +8 -16
- package/src/protocol/handlers/catalog/shared.ts +10 -57
- package/src/protocol/handlers/catalog/table.ts +33 -96
- package/src/protocol/handlers/catalog/view.ts +13 -17
- package/src/protocol/handlers/function.ts +311 -18
- package/src/protocol/handlers/table-buffering.ts +7 -4
- package/src/protocol/serializers/bind.ts +56 -63
- package/src/protocol/serializers/cardinality.ts +7 -7
- package/src/protocol/serializers/init.ts +52 -10
- package/src/protocol/serializers/splits.ts +219 -0
- package/src/protocol/state-serializer.ts +55 -1
- package/src/protocol/types.ts +27 -0
- package/src/request-auth.ts +73 -0
- package/src/serve-entry.ts +5 -0
- package/src/split-token.ts +297 -0
- package/src/worker-cf-entry.ts +2 -1
- package/src/worker.ts +30 -10
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
// Request-scoped caller identity, for the code paths the RPC framework cannot
|
|
3
|
+
// hand a CallContext to.
|
|
4
|
+
//
|
|
5
|
+
// Every unary VGI method receives the caller's `AuthContext` directly — the
|
|
6
|
+
// dispatcher passes the OutputCollector, which *is* a CallContext. The stream
|
|
7
|
+
// `init` method does not: `@query-farm/vgi-rpc` types stream initialization as
|
|
8
|
+
// `(params) => state`, with no context parameter, on both the pipe and HTTP
|
|
9
|
+
// dispatchers. That is a framework signature this package cannot change.
|
|
10
|
+
//
|
|
11
|
+
// It matters because `init` opens two identity-bound AEAD envelopes:
|
|
12
|
+
// `attach_opaque_data` (sealed under `attachAad(auth)` by `catalog_attach`) and
|
|
13
|
+
// every split token (sealed under `splitTokenAad(body, auth)` by
|
|
14
|
+
// `table_function_plan`). Both of those minting sites ARE unary and therefore
|
|
15
|
+
// seal under the REAL principal. Opening them under a hard-coded `undefined`
|
|
16
|
+
// yields the anonymous identity tail, so the AAD only ever matches for an
|
|
17
|
+
// anonymous caller: an authenticated one gets `OpaqueDataRejectedError:
|
|
18
|
+
// attach_opaque_data not recognized` on the first scan. Anonymous workers never
|
|
19
|
+
// saw it, which is why it survived — the example HTTP workers authenticate
|
|
20
|
+
// nobody.
|
|
21
|
+
//
|
|
22
|
+
// The reference implementations solve this with an ambient, request-scoped
|
|
23
|
+
// identity rather than a parameter: vgi-python reads a `ContextVar` via
|
|
24
|
+
// `current_auth()`; vgi-go's RPC framework threads a `*CallContext` into
|
|
25
|
+
// `handleInit`. This is the TypeScript equivalent — an `AsyncLocalStorage`
|
|
26
|
+
// scope opened per HTTP request by `createVgiFetch` (see `http/fetch.ts`),
|
|
27
|
+
// read here.
|
|
28
|
+
//
|
|
29
|
+
// Deliberately a registry rather than a direct `node:async_hooks` import: this
|
|
30
|
+
// module is reachable from the protocol handlers, which also run under stdio,
|
|
31
|
+
// AF_UNIX and browser builds. Only the HTTP entry points install a scope, and
|
|
32
|
+
// they load `node:async_hooks` defensively. With no scope installed
|
|
33
|
+
// `currentRequestAuth()` returns `undefined`, which is exactly the previous
|
|
34
|
+
// behaviour — and correct, because a worker with no signing key seals nothing.
|
|
35
|
+
|
|
36
|
+
import type { AuthContext } from "@query-farm/vgi-rpc";
|
|
37
|
+
|
|
38
|
+
/** Mutable per-request cell. The scope is opened before the identity is known
|
|
39
|
+
* (authentication happens inside the handler), so the holder is published
|
|
40
|
+
* first and filled in when the framework reports the principal. */
|
|
41
|
+
export interface RequestAuthHolder {
|
|
42
|
+
auth?: AuthContext;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The slice of `AsyncLocalStorage` this module needs. Structural on purpose:
|
|
46
|
+
* it keeps `node:async_hooks` out of every non-HTTP build, and lets a test
|
|
47
|
+
* install a trivial stub. */
|
|
48
|
+
export interface RequestAuthScope {
|
|
49
|
+
getStore(): RequestAuthHolder | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let scope: RequestAuthScope | undefined;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Publish the scope `currentRequestAuth()` reads. Called once per process by
|
|
56
|
+
* the HTTP entry point; idempotent for the same scope.
|
|
57
|
+
*/
|
|
58
|
+
export function setRequestAuthScope(next: RequestAuthScope | undefined): void {
|
|
59
|
+
scope = next;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The authenticated principal of the request being served on this async
|
|
64
|
+
* context, or `undefined` when there is none — no scope installed (stdio /
|
|
65
|
+
* AF_UNIX / a host that has no `AsyncLocalStorage`), or an unauthenticated
|
|
66
|
+
* request.
|
|
67
|
+
*
|
|
68
|
+
* `undefined` and an anonymous `AuthContext` produce the same identity tail, so
|
|
69
|
+
* the two cases need not be distinguished by callers.
|
|
70
|
+
*/
|
|
71
|
+
export function currentRequestAuth(): AuthContext | undefined {
|
|
72
|
+
return scope?.getStore()?.auth;
|
|
73
|
+
}
|
package/src/serve-entry.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import type { CatalogInterface } from "./catalog/interface.js";
|
|
25
25
|
import type { FunctionRegistry } from "./functions/registry.js";
|
|
26
|
+
import type { AuthenticateFn } from "@query-farm/vgi-rpc";
|
|
26
27
|
import { createVgiFetch } from "./http/fetch.js";
|
|
27
28
|
|
|
28
29
|
/** Environment variables `serveVgiWorker` reads. Injectable for testing. */
|
|
@@ -68,6 +69,9 @@ export interface ServeVgiWorkerOptions {
|
|
|
68
69
|
quiet?: boolean;
|
|
69
70
|
/** Environment source (default `process.env`). */
|
|
70
71
|
env?: ServeEnv;
|
|
72
|
+
/** Authenticates each request, returning the caller's `AuthContext`.
|
|
73
|
+
* Omit for an anonymous worker (the default). */
|
|
74
|
+
authenticate?: AuthenticateFn;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
/** The slice of `Bun.serve`'s return value this module uses. */
|
|
@@ -194,6 +198,7 @@ export function createVgiWorkerFetch(
|
|
|
194
198
|
corsOrigins,
|
|
195
199
|
repositoryUrl: opts.repositoryUrl,
|
|
196
200
|
landingInfo: { name: opts.name, doc: opts.doc, version: opts.version },
|
|
201
|
+
authenticate: opts.authenticate,
|
|
197
202
|
});
|
|
198
203
|
}
|
|
199
204
|
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Split-token envelope: the framework's wrapper around a worker's split payload.
|
|
5
|
+
*
|
|
6
|
+
* A split token *names* a unit of scan work so a distributed engine can
|
|
7
|
+
* re-request exactly the work it was handed. The worker supplies only the
|
|
8
|
+
* payload; everything around it is stamped here, so an author cannot forget the
|
|
9
|
+
* consistency anchor or mis-bind the fingerprint, and never writes crypto.
|
|
10
|
+
*
|
|
11
|
+
* Layout (little-endian, fixed prefix) — byte-identical across every SDK:
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* offset size field
|
|
15
|
+
* 0 1 format_version currently 1
|
|
16
|
+
* 1 1 flags bit0 = payload_sealed; bits 1-7 reserved, MUST be 0
|
|
17
|
+
* 2 2 anchor_len u16 LE
|
|
18
|
+
* 4 16 bind_fingerprint truncated SHA-256 of the bind identity
|
|
19
|
+
* 20 var consistency_anchor anchor_len bytes
|
|
20
|
+
* 20+n var payload the worker's own bytes
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* **The header is plaintext on every transport; only the payload is sealed.**
|
|
24
|
+
* That is not a preference: a worker has no signing key on subprocess and unix,
|
|
25
|
+
* which is DuckDB's primary path, so a header readable only through AEAD would be
|
|
26
|
+
* unreadable exactly where DuckDB runs. It also matters for streaming — a
|
|
27
|
+
* checkpointed position must survive key rotation.
|
|
28
|
+
*
|
|
29
|
+
* @module
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { AuthContext } from "@query-farm/vgi-rpc";
|
|
33
|
+
|
|
34
|
+
import { identityTail, openBytes, sealBytes } from "./crypto.js";
|
|
35
|
+
|
|
36
|
+
/** Envelope format version. Checked unconditionally, before anything else. */
|
|
37
|
+
export const SPLIT_TOKEN_FORMAT_VERSION = 1;
|
|
38
|
+
|
|
39
|
+
/** bit0 of `flags`: the payload is AEAD-sealed rather than plaintext. */
|
|
40
|
+
const FLAG_PAYLOAD_SEALED = 0x01;
|
|
41
|
+
|
|
42
|
+
/** bits 1-7 are reserved and MUST be zero; a set bit is a forward-compat violation. */
|
|
43
|
+
const RESERVED_FLAGS_MASK = 0xfe;
|
|
44
|
+
|
|
45
|
+
const FINGERPRINT_LEN = 16;
|
|
46
|
+
const HEADER_LEN = 4 + FINGERPRINT_LEN;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Matches the reference default (`crypto.seal_bytes` version=1), so a token
|
|
50
|
+
* sealed by one SDK opens in another.
|
|
51
|
+
*/
|
|
52
|
+
const SEAL_VERSION = 1;
|
|
53
|
+
|
|
54
|
+
const AAD_PREFIX = new TextEncoder().encode("vgi.split_token.v1\u0000");
|
|
55
|
+
|
|
56
|
+
/** Stable error-kind strings, identical across SDKs. */
|
|
57
|
+
export const SPLIT_TOKEN_INVALID = "SPLIT_TOKEN_INVALID";
|
|
58
|
+
export const SPLIT_SNAPSHOT_EXPIRED = "SPLIT_SNAPSHOT_EXPIRED";
|
|
59
|
+
export const SPLIT_TRANSACTION_ENDED = "SPLIT_TRANSACTION_ENDED";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Why a split token was refused.
|
|
63
|
+
*
|
|
64
|
+
* The kind matters to a connector: only `SPLIT_SNAPSHOT_EXPIRED` means "re-run
|
|
65
|
+
* the query", and neither kind is retriable in place. Keeping the anchor in the
|
|
66
|
+
* PLAINTEXT header rather than in the AAD is what makes that distinction
|
|
67
|
+
* expressible — inside the AAD both collapse into one tag-check failure.
|
|
68
|
+
*/
|
|
69
|
+
export class SplitTokenError extends Error {
|
|
70
|
+
readonly kind: string;
|
|
71
|
+
|
|
72
|
+
constructor(kind: string, message: string) {
|
|
73
|
+
// The message carries the stable KIND, because the kind is the part a caller
|
|
74
|
+
// acts on: only SPLIT_SNAPSHOT_EXPIRED means "re-run the query", and a
|
|
75
|
+
// connector several layers up sees the message string rather than this
|
|
76
|
+
// class. Without it the three failures are indistinguishable to everyone
|
|
77
|
+
// downstream.
|
|
78
|
+
super(`[${kind}] ${message}`);
|
|
79
|
+
this.name = "SplitTokenError";
|
|
80
|
+
this.kind = kind;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function concat(...parts: Uint8Array[]): Uint8Array {
|
|
85
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
86
|
+
const out = new Uint8Array(total);
|
|
87
|
+
let at = 0;
|
|
88
|
+
for (const p of parts) {
|
|
89
|
+
out.set(p, at);
|
|
90
|
+
at += p.length;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function equalBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
96
|
+
if (a.length !== b.length) return false;
|
|
97
|
+
let diff = 0;
|
|
98
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
99
|
+
return diff === 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Derive the 16-byte binding check for a bind call.
|
|
104
|
+
*
|
|
105
|
+
* Minted **and** verified by the same worker, so it needs self-consistency only
|
|
106
|
+
* — it does not have to agree with any client, which is why the cross-SDK byte
|
|
107
|
+
* fixtures do not cover it. 16 bytes is a binding check, not a MAC: forgery
|
|
108
|
+
* resistance comes from the seal where a key exists, and from the uid trust
|
|
109
|
+
* boundary where one does not.
|
|
110
|
+
*
|
|
111
|
+
* Uses Web Crypto rather than a hash dependency so this works unchanged in the
|
|
112
|
+
* browser and Cloudflare Workers builds.
|
|
113
|
+
*/
|
|
114
|
+
export async function bindFingerprint(
|
|
115
|
+
schemaName: string,
|
|
116
|
+
functionName: string,
|
|
117
|
+
args: Uint8Array,
|
|
118
|
+
settings: Uint8Array,
|
|
119
|
+
projection: Uint8Array,
|
|
120
|
+
): Promise<Uint8Array> {
|
|
121
|
+
const enc = new TextEncoder();
|
|
122
|
+
const zero = Uint8Array.of(0);
|
|
123
|
+
const field = (label: string, value: Uint8Array) => concat(enc.encode(label), zero, value, zero);
|
|
124
|
+
const message = concat(
|
|
125
|
+
AAD_PREFIX,
|
|
126
|
+
field("schema_name", enc.encode(schemaName)),
|
|
127
|
+
field("function_name", enc.encode(functionName)),
|
|
128
|
+
field("arguments", args),
|
|
129
|
+
field("settings", settings),
|
|
130
|
+
field("projection_ids", projection),
|
|
131
|
+
);
|
|
132
|
+
const digest = await crypto.subtle.digest("SHA-256", message as BufferSource);
|
|
133
|
+
return new Uint8Array(digest).slice(0, FINGERPRINT_LEN);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* AAD for a sealed split payload: the plaintext header plus the caller identity.
|
|
138
|
+
*
|
|
139
|
+
* The identity half is load-bearing, not incidental — it stops a token minted for
|
|
140
|
+
* one principal being replayed by another, exactly as the attach envelope does. A
|
|
141
|
+
* split token names data (files, offsets, tenant partitions), so dropping it here
|
|
142
|
+
* while keeping it on attach would be a regression.
|
|
143
|
+
*/
|
|
144
|
+
function splitTokenAad(header: Uint8Array, auth: AuthContext | undefined): Uint8Array {
|
|
145
|
+
return concat(header, identityTail(auth));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Encode the consistency anchor.
|
|
150
|
+
*
|
|
151
|
+
* `catalog_version` is the counter that MOVES within an attach, so it is what a
|
|
152
|
+
* plan is pinned to; `resolved_data_version` is fixed at attach and would say
|
|
153
|
+
* nothing about staleness.
|
|
154
|
+
*/
|
|
155
|
+
export function splitAnchor(catalogVersion: bigint | number): Uint8Array {
|
|
156
|
+
const out = new Uint8Array(8);
|
|
157
|
+
new DataView(out.buffer).setBigInt64(0, BigInt(catalogVersion), true);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Stamp (and, when a key exists, seal) a worker payload into a split token. */
|
|
162
|
+
export async function buildSplitToken(opts: {
|
|
163
|
+
payload: Uint8Array;
|
|
164
|
+
fingerprint: Uint8Array;
|
|
165
|
+
anchor: Uint8Array;
|
|
166
|
+
signingKey?: Uint8Array;
|
|
167
|
+
auth?: AuthContext;
|
|
168
|
+
}): Promise<Uint8Array> {
|
|
169
|
+
const { payload, fingerprint, anchor, signingKey, auth } = opts;
|
|
170
|
+
if (fingerprint.length !== FINGERPRINT_LEN) {
|
|
171
|
+
throw new SplitTokenError(
|
|
172
|
+
SPLIT_TOKEN_INVALID,
|
|
173
|
+
`bind_fingerprint must be ${FINGERPRINT_LEN} bytes, got ${fingerprint.length}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (anchor.length > 0xffff) {
|
|
177
|
+
throw new SplitTokenError(
|
|
178
|
+
SPLIT_TOKEN_INVALID,
|
|
179
|
+
`consistency_anchor too long: ${anchor.length} bytes exceeds u16`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const head = new Uint8Array(4);
|
|
184
|
+
head[0] = SPLIT_TOKEN_FORMAT_VERSION;
|
|
185
|
+
head[1] = signingKey ? FLAG_PAYLOAD_SEALED : 0;
|
|
186
|
+
new DataView(head.buffer).setUint16(2, anchor.length, true);
|
|
187
|
+
const body = concat(head, fingerprint, anchor);
|
|
188
|
+
|
|
189
|
+
if (!signingKey) return concat(body, payload);
|
|
190
|
+
const sealed = await sealBytes(payload, signingKey, splitTokenAad(body, auth), SEAL_VERSION);
|
|
191
|
+
return concat(body, sealed);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Verify a split token and return the worker's payload.
|
|
196
|
+
*
|
|
197
|
+
* `expectedFingerprint` and `currentAnchor` are optional; omitting one skips that
|
|
198
|
+
* check.
|
|
199
|
+
*/
|
|
200
|
+
export async function openSplitToken(
|
|
201
|
+
token: Uint8Array,
|
|
202
|
+
opts: {
|
|
203
|
+
signingKey?: Uint8Array;
|
|
204
|
+
auth?: AuthContext;
|
|
205
|
+
expectedFingerprint?: Uint8Array;
|
|
206
|
+
currentAnchor?: Uint8Array;
|
|
207
|
+
} = {},
|
|
208
|
+
): Promise<Uint8Array> {
|
|
209
|
+
const { signingKey, auth, expectedFingerprint, currentAnchor } = opts;
|
|
210
|
+
|
|
211
|
+
if (token.length < HEADER_LEN) {
|
|
212
|
+
throw new SplitTokenError(
|
|
213
|
+
SPLIT_TOKEN_INVALID,
|
|
214
|
+
`split token too short: ${token.length} bytes, need at least ${HEADER_LEN}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const version = token[0];
|
|
218
|
+
const flags = token[1];
|
|
219
|
+
const view = new DataView(token.buffer, token.byteOffset, token.byteLength);
|
|
220
|
+
const anchorLen = view.getUint16(2, true);
|
|
221
|
+
|
|
222
|
+
if (version !== SPLIT_TOKEN_FORMAT_VERSION) {
|
|
223
|
+
throw new SplitTokenError(
|
|
224
|
+
SPLIT_TOKEN_INVALID,
|
|
225
|
+
`unsupported split-token format_version ${version}; this worker speaks ` +
|
|
226
|
+
`${SPLIT_TOKEN_FORMAT_VERSION}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (flags & RESERVED_FLAGS_MASK) {
|
|
230
|
+
throw new SplitTokenError(
|
|
231
|
+
SPLIT_TOKEN_INVALID,
|
|
232
|
+
`split token sets reserved flag bits (flags=0x${flags.toString(16).padStart(2, "0")})`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const sealed = (flags & FLAG_PAYLOAD_SEALED) !== 0;
|
|
236
|
+
|
|
237
|
+
// ---- The alg:none refusal. Load-bearing; do not relax. ----
|
|
238
|
+
// `flags` is attacker-controlled plaintext, so it may say "not sealed" on a
|
|
239
|
+
// token an attacker wrote by hand. A keyed worker that honoured that would
|
|
240
|
+
// redeem forged work without ever opening an envelope. The WORKER'S OWN KEY
|
|
241
|
+
// STATE decides, never the token.
|
|
242
|
+
if (signingKey && !sealed) {
|
|
243
|
+
throw new SplitTokenError(
|
|
244
|
+
SPLIT_TOKEN_INVALID,
|
|
245
|
+
"split token is unsealed but this worker holds a signing key; refusing. An unsealed " +
|
|
246
|
+
"token cannot be authenticated, so accepting one here would let any caller forge a " +
|
|
247
|
+
"split (alg:none).",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (!signingKey && sealed) {
|
|
251
|
+
throw new SplitTokenError(
|
|
252
|
+
SPLIT_TOKEN_INVALID,
|
|
253
|
+
"split token is sealed but this worker holds no signing key; cannot open it",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const endOfAnchor = HEADER_LEN + anchorLen;
|
|
258
|
+
if (token.length < endOfAnchor) {
|
|
259
|
+
throw new SplitTokenError(
|
|
260
|
+
SPLIT_TOKEN_INVALID,
|
|
261
|
+
`split token truncated: anchor_len=${anchorLen} exceeds token length ${token.length}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const fingerprint = token.subarray(4, HEADER_LEN);
|
|
266
|
+
const anchor = token.subarray(HEADER_LEN, endOfAnchor);
|
|
267
|
+
const body = token.subarray(0, endOfAnchor);
|
|
268
|
+
const rest = token.subarray(endOfAnchor);
|
|
269
|
+
|
|
270
|
+
if (expectedFingerprint && !equalBytes(fingerprint, expectedFingerprint)) {
|
|
271
|
+
throw new SplitTokenError(
|
|
272
|
+
SPLIT_TOKEN_INVALID,
|
|
273
|
+
"split token was minted for a different bind (fingerprint mismatch)",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
// Anchor check AFTER the bind check, and as its own kind: "read version N" is
|
|
277
|
+
// a different situation from "this token is not yours".
|
|
278
|
+
if (currentAnchor && !equalBytes(anchor, currentAnchor)) {
|
|
279
|
+
throw new SplitTokenError(SPLIT_SNAPSHOT_EXPIRED, "split snapshot expired; re-run the query");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!sealed) return new Uint8Array(rest);
|
|
283
|
+
try {
|
|
284
|
+
return await openBytes(
|
|
285
|
+
rest,
|
|
286
|
+
signingKey as Uint8Array,
|
|
287
|
+
splitTokenAad(body, auth),
|
|
288
|
+
SEAL_VERSION,
|
|
289
|
+
"split token",
|
|
290
|
+
);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
throw new SplitTokenError(
|
|
293
|
+
SPLIT_TOKEN_INVALID,
|
|
294
|
+
`split token failed authentication: ${err instanceof Error ? err.message : String(err)}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
package/src/worker-cf-entry.ts
CHANGED
|
@@ -17,7 +17,8 @@ export { createVgiFetch } from "./http/fetch.js";
|
|
|
17
17
|
export type { VgiFetchOptions } from "./http/fetch.js";
|
|
18
18
|
|
|
19
19
|
// Re-export the public types CF Worker authors need to wire a registry +
|
|
20
|
-
// catalog. They import from "vgi/worker-cf" rather than from
|
|
20
|
+
// catalog. They import from "@query-farm/vgi/worker-cf" rather than from
|
|
21
|
+
// "@query-farm/vgi" directly
|
|
21
22
|
// so the build-condition resolution picks the flechette Arrow backend.
|
|
22
23
|
export {
|
|
23
24
|
defineScalarFunction,
|
package/src/worker.ts
CHANGED
|
@@ -10,6 +10,26 @@ import type { CatalogDescriptor } from "./catalog/descriptors.js";
|
|
|
10
10
|
import type { CatalogInterface } from "./catalog/interface.js";
|
|
11
11
|
import { ReadOnlyCatalogInterface } from "./catalog/read-only.js";
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Startup/lifecycle tracing, off unless `VGI_DEBUG` is set to something other
|
|
15
|
+
* than "" or "0".
|
|
16
|
+
*
|
|
17
|
+
* These lines used to be unconditional, which put three of them in the log of
|
|
18
|
+
* every worker anyone ever deployed. They are genuinely useful when an ATTACH
|
|
19
|
+
* hangs and you need to know how far startup got, so they are kept rather than
|
|
20
|
+
* deleted — just behind a flag. Errors are NOT gated: `Worker error:` and
|
|
21
|
+
* `Worker init error:` always print.
|
|
22
|
+
*
|
|
23
|
+
* The message is a thunk so an unset flag costs no interpolation.
|
|
24
|
+
*
|
|
25
|
+
* stderr, never stdout: on the stdio transport stdout is the protocol.
|
|
26
|
+
*/
|
|
27
|
+
function debug(message: () => string): void {
|
|
28
|
+
const flag = process.env.VGI_DEBUG;
|
|
29
|
+
if (flag === undefined || flag === "" || flag === "0") return;
|
|
30
|
+
process.stderr.write(message() + "\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
// argv parser for the launcher's `--unix PATH` / `--tcp [HOST:]PORT` /
|
|
14
34
|
// `--idle-timeout SEC` contract. The C++ launcher (vgi extension) appends
|
|
15
35
|
// these to the worker's argv whenever LOCATION uses the `launch:` scheme.
|
|
@@ -123,10 +143,10 @@ export class Worker {
|
|
|
123
143
|
}
|
|
124
144
|
|
|
125
145
|
run(argv: readonly string[] = process.argv.slice(2)): void {
|
|
126
|
-
|
|
146
|
+
debug(() => `[worker] starting, pid=${process.pid}`);
|
|
127
147
|
try {
|
|
128
148
|
const protocol = this.buildProtocol();
|
|
129
|
-
|
|
149
|
+
debug(() => `[worker] protocol built`);
|
|
130
150
|
|
|
131
151
|
const launcher = parseLauncherArgs(argv);
|
|
132
152
|
if (launcher.unixPath !== undefined) {
|
|
@@ -139,14 +159,14 @@ export class Worker {
|
|
|
139
159
|
const idleTimeout = envOverride !== undefined && envOverride !== ""
|
|
140
160
|
? Number(envOverride)
|
|
141
161
|
: launcher.idleTimeout;
|
|
142
|
-
|
|
143
|
-
`[worker] AF_UNIX mode: ${launcher.unixPath} idle=${idleTimeout ?? 300}s${envOverride !== undefined && envOverride !== "" ? " (env override)" : ""}
|
|
162
|
+
debug(() =>
|
|
163
|
+
`[worker] AF_UNIX mode: ${launcher.unixPath} idle=${idleTimeout ?? 300}s${envOverride !== undefined && envOverride !== "" ? " (env override)" : ""}`,
|
|
144
164
|
);
|
|
145
165
|
serveUnix(protocol, {
|
|
146
166
|
unixPath: launcher.unixPath,
|
|
147
167
|
idleTimeout,
|
|
148
168
|
}).then((handle) => handle.done).then(() => {
|
|
149
|
-
|
|
169
|
+
debug(() => `[worker] serveUnix done (idle shutdown)`);
|
|
150
170
|
}).catch((err: any) => {
|
|
151
171
|
process.stderr.write(`Worker error: ${err.message}\n${err.stack}\n`);
|
|
152
172
|
process.exit(1);
|
|
@@ -164,15 +184,15 @@ export class Worker {
|
|
|
164
184
|
const idleTimeout = envOverride !== undefined && envOverride !== ""
|
|
165
185
|
? Number(envOverride)
|
|
166
186
|
: launcher.idleTimeout;
|
|
167
|
-
|
|
168
|
-
`[worker] TCP mode: ${launcher.tcpHost}:${launcher.tcpPort} idle=${idleTimeout ?? 300}s${envOverride !== undefined && envOverride !== "" ? " (env override)" : ""}
|
|
187
|
+
debug(() =>
|
|
188
|
+
`[worker] TCP mode: ${launcher.tcpHost}:${launcher.tcpPort} idle=${idleTimeout ?? 300}s${envOverride !== undefined && envOverride !== "" ? " (env override)" : ""}`,
|
|
169
189
|
);
|
|
170
190
|
serveTcp(protocol, {
|
|
171
191
|
host: launcher.tcpHost,
|
|
172
192
|
port: launcher.tcpPort,
|
|
173
193
|
idleTimeout,
|
|
174
194
|
}).then((handle) => handle.done).then(() => {
|
|
175
|
-
|
|
195
|
+
debug(() => `[worker] serveTcp done (idle shutdown)`);
|
|
176
196
|
}).catch((err: any) => {
|
|
177
197
|
process.stderr.write(`Worker error: ${err.message}\n${err.stack}\n`);
|
|
178
198
|
process.exit(1);
|
|
@@ -181,9 +201,9 @@ export class Worker {
|
|
|
181
201
|
}
|
|
182
202
|
|
|
183
203
|
const server = new VgiRpcServer(protocol);
|
|
184
|
-
|
|
204
|
+
debug(() => `[worker] server created, calling run()`);
|
|
185
205
|
server.run().then(() => {
|
|
186
|
-
|
|
206
|
+
debug(() => `[worker] server.run() resolved cleanly`);
|
|
187
207
|
}).catch((err) => {
|
|
188
208
|
process.stderr.write(`Worker error: ${err.message}\n${err.stack}\n`);
|
|
189
209
|
process.exit(1);
|