@query-farm/vgi-rpc 0.6.4 → 0.7.1
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/dist/access-log.d.ts +55 -0
- package/dist/access-log.d.ts.map +1 -0
- package/dist/arrow/impl-arrowjs/index.d.ts +96 -0
- package/dist/arrow/impl-arrowjs/index.d.ts.map +1 -0
- package/dist/arrow/impl-flechette/index.d.ts +102 -0
- package/dist/arrow/impl-flechette/index.d.ts.map +1 -0
- package/dist/arrow/impl-flechette/message-meta.d.ts +11 -0
- package/dist/arrow/impl-flechette/message-meta.d.ts.map +1 -0
- package/dist/arrow/index.d.ts +4 -0
- package/dist/arrow/index.d.ts.map +1 -0
- package/dist/arrow/predicates.d.ts +44 -0
- package/dist/arrow/predicates.d.ts.map +1 -0
- package/dist/arrow/types.d.ts +62 -0
- package/dist/arrow/types.d.ts.map +1 -0
- package/dist/auth.d.ts +5 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/client/capabilities.d.ts +25 -0
- package/dist/client/capabilities.d.ts.map +1 -0
- package/dist/client/connect.d.ts +10 -0
- package/dist/client/connect.d.ts.map +1 -1
- package/dist/client/introspect.d.ts +21 -0
- package/dist/client/introspect.d.ts.map +1 -1
- package/dist/client/ipc.d.ts +8 -2
- package/dist/client/ipc.d.ts.map +1 -1
- package/dist/client/oauth.d.ts +9 -0
- package/dist/client/oauth.d.ts.map +1 -1
- package/dist/client/pipe.d.ts +24 -0
- package/dist/client/pipe.d.ts.map +1 -1
- package/dist/client/stream.d.ts +19 -2
- package/dist/client/stream.d.ts.map +1 -1
- package/dist/client/types.d.ts +23 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/uploadUrl.d.ts +25 -0
- package/dist/client/uploadUrl.d.ts.map +1 -0
- package/dist/constants.d.ts +30 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/crypto.d.ts +22 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/dispatch/describe.d.ts +10 -6
- package/dist/dispatch/describe.d.ts.map +1 -1
- package/dist/dispatch/stream.d.ts +2 -2
- package/dist/dispatch/stream.d.ts.map +1 -1
- package/dist/dispatch/unary.d.ts +2 -2
- package/dist/dispatch/unary.d.ts.map +1 -1
- package/dist/errors.d.ts +64 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/external.d.ts +27 -5
- package/dist/external.d.ts.map +1 -1
- package/dist/http/auth.d.ts +13 -0
- package/dist/http/auth.d.ts.map +1 -1
- package/dist/http/bearer.d.ts.map +1 -1
- package/dist/http/common.d.ts +43 -7
- package/dist/http/common.d.ts.map +1 -1
- package/dist/http/dispatch.d.ts +20 -2
- package/dist/http/dispatch.d.ts.map +1 -1
- package/dist/http/handler.d.ts.map +1 -1
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/jwt.d.ts +1 -0
- package/dist/http/jwt.d.ts.map +1 -1
- package/dist/http/mtls.d.ts +9 -1
- package/dist/http/mtls.d.ts.map +1 -1
- package/dist/http/oauth-pkce.d.ts +141 -0
- package/dist/http/oauth-pkce.d.ts.map +1 -0
- package/dist/http/pages.d.ts +3 -0
- package/dist/http/pages.d.ts.map +1 -1
- package/dist/http/sticky.d.ts +124 -0
- package/dist/http/sticky.d.ts.map +1 -0
- package/dist/http/token.d.ts +43 -12
- package/dist/http/token.d.ts.map +1 -1
- package/dist/http/types.d.ts +68 -5
- package/dist/http/types.d.ts.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1275 -3511
- package/dist/index.js.map +20 -38
- package/dist/launcher/hash.d.ts +22 -0
- package/dist/launcher/hash.d.ts.map +1 -0
- package/dist/launcher/index.d.ts +23 -0
- package/dist/launcher/index.d.ts.map +1 -0
- package/dist/launcher/launch.d.ts +27 -0
- package/dist/launcher/launch.d.ts.map +1 -0
- package/dist/launcher/lock.d.ts +19 -0
- package/dist/launcher/lock.d.ts.map +1 -0
- package/dist/launcher/serve-unix.d.ts +55 -0
- package/dist/launcher/serve-unix.d.ts.map +1 -0
- package/dist/launcher/state.d.ts +71 -0
- package/dist/launcher/state.d.ts.map +1 -0
- package/dist/otel.d.ts.map +1 -1
- package/dist/protocol.d.ts +19 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/schema.d.ts +45 -18
- package/dist/schema.d.ts.map +1 -1
- package/dist/server.d.ts +23 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/types.d.ts +270 -12
- package/dist/types.d.ts.map +1 -1
- package/dist/util/gzip.d.ts +10 -0
- package/dist/util/gzip.d.ts.map +1 -0
- package/dist/util/schema.d.ts +3 -15
- package/dist/util/schema.d.ts.map +1 -1
- package/dist/util/web-crypto.d.ts +22 -0
- package/dist/util/web-crypto.d.ts.map +1 -0
- package/dist/util/zstd.d.ts +26 -3
- package/dist/util/zstd.d.ts.map +1 -1
- package/dist/wire/opaque.d.ts +11 -0
- package/dist/wire/opaque.d.ts.map +1 -0
- package/dist/wire/reader.d.ts +5 -5
- package/dist/wire/reader.d.ts.map +1 -1
- package/dist/wire/request.d.ts +11 -3
- package/dist/wire/request.d.ts.map +1 -1
- package/dist/wire/response.d.ts +6 -6
- package/dist/wire/response.d.ts.map +1 -1
- package/dist/wire/writer.d.ts +49 -39
- package/dist/wire/writer.d.ts.map +1 -1
- package/package.json +35 -21
- package/src/access-log.ts +200 -0
- package/src/arrow/impl-arrowjs/index.ts +433 -0
- package/src/arrow/impl-flechette/index.ts +414 -0
- package/src/arrow/impl-flechette/message-meta.ts +174 -0
- package/src/arrow/index.ts +89 -0
- package/src/arrow/predicates.ts +56 -0
- package/src/arrow/types.ts +73 -0
- package/src/auth.ts +5 -0
- package/src/client/capabilities.ts +84 -0
- package/src/client/connect.ts +113 -26
- package/src/client/introspect.ts +74 -38
- package/src/client/ipc.ts +37 -27
- package/src/client/oauth.ts +9 -0
- package/src/client/pipe.ts +36 -9
- package/src/client/stream.ts +43 -20
- package/src/client/types.ts +23 -0
- package/src/client/uploadUrl.ts +169 -0
- package/src/constants.ts +34 -2
- package/src/crypto.ts +95 -0
- package/src/dispatch/describe.ts +146 -107
- package/src/dispatch/stream.ts +53 -24
- package/src/dispatch/unary.ts +5 -4
- package/src/errors.ts +87 -0
- package/src/external.ts +49 -30
- package/src/http/auth.ts +13 -0
- package/src/http/bearer.ts +2 -5
- package/src/http/common.ts +91 -23
- package/src/http/dispatch.ts +373 -46
- package/src/http/handler.ts +790 -68
- package/src/http/index.ts +1 -0
- package/src/http/jwt.ts +1 -0
- package/src/http/mtls.ts +25 -3
- package/src/http/oauth-pkce.ts +1035 -0
- package/src/http/pages.ts +30 -15
- package/src/http/sticky.ts +429 -0
- package/src/http/token.ts +170 -75
- package/src/http/types.ts +69 -5
- package/src/index.ts +40 -1
- package/src/launcher/hash.ts +104 -0
- package/src/launcher/index.ts +35 -0
- package/src/launcher/launch.ts +284 -0
- package/src/launcher/lock.ts +171 -0
- package/src/launcher/serve-unix.ts +386 -0
- package/src/launcher/state.ts +257 -0
- package/src/otel.ts +39 -33
- package/src/protocol.ts +30 -3
- package/src/schema.ts +107 -56
- package/src/server.ts +196 -20
- package/src/types.ts +376 -18
- package/src/util/gzip.ts +63 -0
- package/src/util/schema.ts +4 -22
- package/src/util/web-crypto.ts +98 -0
- package/src/util/zstd.ts +133 -14
- package/src/wire/opaque.ts +37 -0
- package/src/wire/reader.ts +5 -4
- package/src/wire/request.ts +67 -8
- package/src/wire/response.ts +51 -85
- package/src/wire/writer.ts +165 -69
- package/dist/util/conform.d.ts +0 -18
- package/dist/util/conform.d.ts.map +0 -1
- package/src/util/conform.ts +0 -94
package/src/http/token.ts
CHANGED
|
@@ -1,112 +1,210 @@
|
|
|
1
1
|
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { openBytes, SealError, sealBytes } from "../crypto.js";
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
const _UTF8 = new TextEncoder();
|
|
7
|
+
|
|
8
|
+
const TOKEN_VERSION = 4;
|
|
9
|
+
|
|
10
|
+
const AAD_PREFIX = _UTF8.encode("vgi_rpc.state.v4\0");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build the AEAD associated data that binds a state token to its issuing
|
|
14
|
+
* principal. Anonymous and authenticated tokens produce distinct AAD
|
|
15
|
+
* strings, so an anonymous token cannot be opened by a named identity
|
|
16
|
+
* (and vice versa).
|
|
17
|
+
*/
|
|
18
|
+
export function computeAad(principal: string | null | undefined): Uint8Array {
|
|
19
|
+
if (!principal) {
|
|
20
|
+
const tail = _UTF8.encode("\0anonymous");
|
|
21
|
+
return concatBytes(AAD_PREFIX, tail);
|
|
22
|
+
}
|
|
23
|
+
const pBytes = _UTF8.encode(principal);
|
|
24
|
+
const tail = new Uint8Array(1 + pBytes.length);
|
|
25
|
+
tail[0] = 0x01;
|
|
26
|
+
tail.set(pBytes, 1);
|
|
27
|
+
return concatBytes(AAD_PREFIX, tail);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Base64 helpers — `btoa`/`atob` exist on Node 16+, Bun, and workerd; we work
|
|
31
|
+
// in chunks to stay below the per-call argument limit (Latin-1 only, so we
|
|
32
|
+
// move byte-by-byte through `String.fromCharCode`).
|
|
33
|
+
|
|
34
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
35
|
+
let s = "";
|
|
36
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
37
|
+
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
38
|
+
}
|
|
39
|
+
return btoa(s);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function base64ToBytes(b64: string): Uint8Array {
|
|
43
|
+
const bin = atob(b64);
|
|
44
|
+
const out = new Uint8Array(bin.length);
|
|
45
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Little-endian writers/readers — Buffer was the previous abstraction; we now
|
|
50
|
+
// touch DataView for portability across Node, Bun, and workerd.
|
|
51
|
+
|
|
52
|
+
function writeU32LE(view: DataView, offset: number, value: number): void {
|
|
53
|
+
view.setUint32(offset, value, /* littleEndian */ true);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeU64LE(view: DataView, offset: number, value: bigint): void {
|
|
57
|
+
view.setBigUint64(offset, value, /* littleEndian */ true);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readU32LE(view: DataView, offset: number): number {
|
|
61
|
+
return view.getUint32(offset, /* littleEndian */ true);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readU64LE(view: DataView, offset: number): bigint {
|
|
65
|
+
return view.getBigUint64(offset, /* littleEndian */ true);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function concatBytes(...parts: Uint8Array[]): Uint8Array {
|
|
69
|
+
let total = 0;
|
|
70
|
+
for (const p of parts) total += p.length;
|
|
71
|
+
const out = new Uint8Array(total);
|
|
72
|
+
let offset = 0;
|
|
73
|
+
for (const p of parts) {
|
|
74
|
+
out.set(p, offset);
|
|
75
|
+
offset += p.length;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
10
79
|
|
|
11
80
|
/**
|
|
12
|
-
*
|
|
81
|
+
* Seal a state token with XChaCha20-Poly1305 AEAD (v4 wire format).
|
|
82
|
+
*
|
|
83
|
+
* Layout (base64-encoded):
|
|
84
|
+
*
|
|
85
|
+
* ```
|
|
86
|
+
* [1B version=4]
|
|
87
|
+
* [24B XChaCha20-Poly1305 nonce (random)]
|
|
88
|
+
* [.. ciphertext + 16B Poly1305 tag]
|
|
89
|
+
* plaintext:
|
|
90
|
+
* [8B created_at uint64 LE]
|
|
91
|
+
* [4B state_len uint32 LE] [state_len bytes]
|
|
92
|
+
* [4B schema_len uint32 LE] [schema_len bytes]
|
|
93
|
+
* [4B input_schema_len LE] [input_schema_len bytes]
|
|
94
|
+
* ```
|
|
13
95
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* [32B HMAC-SHA256(signing_key, all above bytes)]
|
|
96
|
+
* `created_at` lives inside the ciphertext so TTL enforcement runs after
|
|
97
|
+
* authenticity. The version byte is informational (a self-describing
|
|
98
|
+
* format marker); a tampered version byte still fails decryption because
|
|
99
|
+
* we use the matching algorithm for that version. `principal` is bound
|
|
100
|
+
* via AEAD associated data so a token minted for one identity fails
|
|
101
|
+
* decryption when presented by another.
|
|
21
102
|
*/
|
|
22
103
|
export function packStateToken(
|
|
23
104
|
stateBytes: Uint8Array,
|
|
24
105
|
schemaBytes: Uint8Array,
|
|
25
106
|
inputSchemaBytes: Uint8Array,
|
|
26
|
-
|
|
107
|
+
tokenKey: Uint8Array,
|
|
108
|
+
principal: string | null | undefined,
|
|
27
109
|
createdAt?: number,
|
|
28
110
|
): string {
|
|
111
|
+
if (tokenKey.length !== 32) {
|
|
112
|
+
throw new Error("XChaCha20-Poly1305 token key must be 32 bytes");
|
|
113
|
+
}
|
|
29
114
|
const now = createdAt ?? Math.floor(Date.now() / 1000);
|
|
30
115
|
|
|
31
|
-
const
|
|
32
|
-
const
|
|
116
|
+
const plaintextLen = 8 + 4 + stateBytes.length + 4 + schemaBytes.length + 4 + inputSchemaBytes.length;
|
|
117
|
+
const plaintext = new Uint8Array(plaintextLen);
|
|
118
|
+
const view = new DataView(plaintext.buffer);
|
|
33
119
|
let offset = 0;
|
|
34
120
|
|
|
35
|
-
|
|
36
|
-
buf.writeUInt8(TOKEN_VERSION, offset);
|
|
37
|
-
offset += 1;
|
|
38
|
-
|
|
39
|
-
// created_at as uint64 LE
|
|
40
|
-
buf.writeBigUInt64LE(BigInt(now), offset);
|
|
121
|
+
writeU64LE(view, offset, BigInt(now));
|
|
41
122
|
offset += 8;
|
|
42
123
|
|
|
43
|
-
|
|
44
|
-
buf.writeUInt32LE(stateBytes.length, offset);
|
|
124
|
+
writeU32LE(view, offset, stateBytes.length);
|
|
45
125
|
offset += 4;
|
|
46
|
-
|
|
126
|
+
plaintext.set(stateBytes, offset);
|
|
47
127
|
offset += stateBytes.length;
|
|
48
128
|
|
|
49
|
-
|
|
50
|
-
buf.writeUInt32LE(schemaBytes.length, offset);
|
|
129
|
+
writeU32LE(view, offset, schemaBytes.length);
|
|
51
130
|
offset += 4;
|
|
52
|
-
|
|
131
|
+
plaintext.set(schemaBytes, offset);
|
|
53
132
|
offset += schemaBytes.length;
|
|
54
133
|
|
|
55
|
-
|
|
56
|
-
buf.writeUInt32LE(inputSchemaBytes.length, offset);
|
|
134
|
+
writeU32LE(view, offset, inputSchemaBytes.length);
|
|
57
135
|
offset += 4;
|
|
58
|
-
|
|
59
|
-
offset += inputSchemaBytes.length;
|
|
136
|
+
plaintext.set(inputSchemaBytes, offset);
|
|
60
137
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const token = Buffer.concat([buf, mac]);
|
|
64
|
-
|
|
65
|
-
return token.toString("base64");
|
|
138
|
+
const wire = sealBytes(plaintext, tokenKey, { aad: computeAad(principal), version: TOKEN_VERSION });
|
|
139
|
+
return bytesToBase64(wire);
|
|
66
140
|
}
|
|
67
141
|
|
|
142
|
+
/** Decrypted payload of a state token, as returned by {@link unpackStateToken}. */
|
|
68
143
|
export interface UnpackedToken {
|
|
144
|
+
/** Serialized stream-state bytes carried by the token. */
|
|
69
145
|
stateBytes: Uint8Array;
|
|
146
|
+
/** Serialized output-schema IPC bytes. */
|
|
70
147
|
schemaBytes: Uint8Array;
|
|
148
|
+
/** Serialized input-schema IPC bytes (exchange streams). */
|
|
71
149
|
inputSchemaBytes: Uint8Array;
|
|
150
|
+
/** Unix epoch seconds at which the token was minted (used for TTL checks). */
|
|
72
151
|
createdAt: number;
|
|
73
152
|
}
|
|
74
153
|
|
|
75
154
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
155
|
+
* Open and verify a state token. Decryption (which checks the Poly1305
|
|
156
|
+
* tag) authenticates the payload; any tampering, wrong key, or AAD
|
|
157
|
+
* mismatch (e.g. cross-principal replay) surfaces as a uniform
|
|
158
|
+
* "signature verification failed" error so callers cannot distinguish
|
|
159
|
+
* failure modes via timing or message content.
|
|
160
|
+
*
|
|
161
|
+
* Throws on tampered, expired, malformed, or unknown-version tokens.
|
|
78
162
|
*/
|
|
79
|
-
export function unpackStateToken(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
163
|
+
export function unpackStateToken(
|
|
164
|
+
tokenBase64: string,
|
|
165
|
+
tokenKey: Uint8Array,
|
|
166
|
+
tokenTtl: number,
|
|
167
|
+
principal: string | null | undefined,
|
|
168
|
+
): UnpackedToken {
|
|
169
|
+
let raw: Uint8Array;
|
|
170
|
+
try {
|
|
171
|
+
raw = base64ToBytes(tokenBase64);
|
|
172
|
+
} catch {
|
|
173
|
+
throw new Error("Malformed state token");
|
|
84
174
|
}
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
175
|
+
// Pre-check the envelope version separately so callers can distinguish
|
|
176
|
+
// "wrong format" from "tampered". Mirrors the pre-refactor error shape.
|
|
177
|
+
if (raw.length >= 1 && raw[0] !== TOKEN_VERSION) {
|
|
178
|
+
throw new Error(`Unsupported state token version: ${raw[0]}`);
|
|
179
|
+
}
|
|
180
|
+
let plaintext: Uint8Array;
|
|
181
|
+
try {
|
|
182
|
+
plaintext = openBytes(raw, tokenKey, { aad: computeAad(principal), version: TOKEN_VERSION });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err instanceof SealError) {
|
|
185
|
+
throw new Error("State token signature verification failed");
|
|
186
|
+
}
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
if (plaintext.length < 8) {
|
|
190
|
+
throw new Error("State token truncated");
|
|
94
191
|
}
|
|
95
192
|
|
|
193
|
+
// Copy each bytes section into a freshly-allocated Uint8Array with
|
|
194
|
+
// byteOffset=0. arrow-js's schema deserializer wraps the result as Int32Array
|
|
195
|
+
// and throws 'RangeError: Byte offset is not aligned' if the slice happens
|
|
196
|
+
// to start at a non-4-aligned offset. Copying normalizes the alignment.
|
|
197
|
+
const view = new DataView(plaintext.buffer, plaintext.byteOffset, plaintext.byteLength);
|
|
96
198
|
let offset = 0;
|
|
199
|
+
const copyAligned = (start: number, len: number) => {
|
|
200
|
+
const out = new Uint8Array(len);
|
|
201
|
+
out.set(plaintext.subarray(start, start + len));
|
|
202
|
+
return out;
|
|
203
|
+
};
|
|
97
204
|
|
|
98
|
-
|
|
99
|
-
const version = payload.readUInt8(offset);
|
|
100
|
-
offset += 1;
|
|
101
|
-
if (version !== TOKEN_VERSION) {
|
|
102
|
-
throw new Error(`Unsupported state token version: ${version}`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// created_at
|
|
106
|
-
const createdAt = Number(payload.readBigUInt64LE(offset));
|
|
205
|
+
const createdAt = Number(readU64LE(view, offset));
|
|
107
206
|
offset += 8;
|
|
108
207
|
|
|
109
|
-
// TTL check
|
|
110
208
|
if (tokenTtl > 0) {
|
|
111
209
|
const now = Math.floor(Date.now() / 1000);
|
|
112
210
|
if (now - createdAt > tokenTtl) {
|
|
@@ -114,31 +212,28 @@ export function unpackStateToken(tokenBase64: string, signingKey: Uint8Array, to
|
|
|
114
212
|
}
|
|
115
213
|
}
|
|
116
214
|
|
|
117
|
-
|
|
118
|
-
const stateLen = payload.readUInt32LE(offset);
|
|
215
|
+
const stateLen = readU32LE(view, offset);
|
|
119
216
|
offset += 4;
|
|
120
|
-
if (offset + stateLen >
|
|
217
|
+
if (offset + stateLen > plaintext.length) {
|
|
121
218
|
throw new Error("State token truncated (state)");
|
|
122
219
|
}
|
|
123
|
-
const stateBytes =
|
|
220
|
+
const stateBytes = copyAligned(offset, stateLen);
|
|
124
221
|
offset += stateLen;
|
|
125
222
|
|
|
126
|
-
|
|
127
|
-
const schemaLen = payload.readUInt32LE(offset);
|
|
223
|
+
const schemaLen = readU32LE(view, offset);
|
|
128
224
|
offset += 4;
|
|
129
|
-
if (offset + schemaLen >
|
|
225
|
+
if (offset + schemaLen > plaintext.length) {
|
|
130
226
|
throw new Error("State token truncated (schema)");
|
|
131
227
|
}
|
|
132
|
-
const schemaBytes =
|
|
228
|
+
const schemaBytes = copyAligned(offset, schemaLen);
|
|
133
229
|
offset += schemaLen;
|
|
134
230
|
|
|
135
|
-
|
|
136
|
-
const inputSchemaLen = payload.readUInt32LE(offset);
|
|
231
|
+
const inputSchemaLen = readU32LE(view, offset);
|
|
137
232
|
offset += 4;
|
|
138
|
-
if (offset + inputSchemaLen >
|
|
233
|
+
if (offset + inputSchemaLen > plaintext.length) {
|
|
139
234
|
throw new Error("State token truncated (input schema)");
|
|
140
235
|
}
|
|
141
|
-
const inputSchemaBytes =
|
|
236
|
+
const inputSchemaBytes = copyAligned(offset, inputSchemaLen);
|
|
142
237
|
|
|
143
238
|
return { stateBytes, schemaBytes, inputSchemaBytes, createdAt };
|
|
144
239
|
}
|
package/src/http/types.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
import type { ExternalLocationConfig } from "../external.js";
|
|
5
|
-
import type { DispatchHook } from "../types.js";
|
|
4
|
+
import type { ExternalLocationConfig, UploadUrlProvider } from "../external.js";
|
|
5
|
+
import type { DispatchHook, ServeStartHook } from "../types.js";
|
|
6
6
|
import type { AuthenticateFn, OAuthResourceMetadata } from "./auth.js";
|
|
7
7
|
|
|
8
8
|
/** Configuration options for createHttpHandler(). */
|
|
9
9
|
export interface HttpHandlerOptions {
|
|
10
10
|
/** URL path prefix for all endpoints. Default: "" (root). */
|
|
11
11
|
prefix?: string;
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/** XChaCha20-Poly1305 master key (32 bytes) used to seal stream state
|
|
13
|
+
* tokens. A random 32-byte key is generated if omitted (tokens won't
|
|
14
|
+
* survive a restart or load-balance across workers). */
|
|
15
|
+
tokenKey?: Uint8Array;
|
|
14
16
|
/** State token time-to-live in seconds. Default: 3600 (1 hour). 0 disables TTL checks. */
|
|
15
17
|
tokenTtl?: number;
|
|
16
18
|
/** CORS allowed origins. If set, CORS headers are added to all responses. */
|
|
@@ -19,8 +21,29 @@ export interface HttpHandlerOptions {
|
|
|
19
21
|
corsMaxAge?: number | null;
|
|
20
22
|
/** Maximum request body size in bytes. Advertised via VGI-Max-Request-Bytes header. */
|
|
21
23
|
maxRequestBytes?: number;
|
|
22
|
-
/**
|
|
24
|
+
/** Cap on the post-decompression size of a `Content-Encoding: zstd`
|
|
25
|
+
* request body, in bytes. Defends against zstd decompression bombs:
|
|
26
|
+
* a tiny compressed frame can declare a huge decompressed size and
|
|
27
|
+
* blow up the server before {@link maxRequestBytes} ever sees the
|
|
28
|
+
* payload. When omitted, defaults to `maxRequestBytes * 16` if that
|
|
29
|
+
* is set, otherwise unbounded. */
|
|
30
|
+
maxDecompressedRequestBytes?: number;
|
|
31
|
+
/** Maximum bytes before a producer stream emits a continuation token.
|
|
32
|
+
*
|
|
33
|
+
* @deprecated Use {@link maxResponseBytes} instead. The cap now governs all
|
|
34
|
+
* HTTP method responses (unary, exchange, producer), not just producer streams.
|
|
35
|
+
*/
|
|
23
36
|
maxStreamResponseBytes?: number;
|
|
37
|
+
/** HTTP body cap. Hard for unary and stream-exchange (overshoot surfaces
|
|
38
|
+
* as 200 + X-VGI-RPC-Error EXCEPTION batch). Soft for producer streams
|
|
39
|
+
* (overshoot mints a continuation token). Externalised payloads do not
|
|
40
|
+
* count toward this — they leave only tiny pointer batches on the wire.
|
|
41
|
+
* Advertised via VGI-Max-Response-Bytes. Undefined = unbounded. */
|
|
42
|
+
maxResponseBytes?: number;
|
|
43
|
+
/** Cap on bytes uploaded to external storage during one HTTP response.
|
|
44
|
+
* Always hard — externalised uploads have no escape valve. Advertised via
|
|
45
|
+
* VGI-Max-Externalized-Response-Bytes. Undefined = unbounded. */
|
|
46
|
+
maxExternalizedResponseBytes?: number;
|
|
24
47
|
/** Server ID included in response metadata. Random if omitted. */
|
|
25
48
|
serverId?: string;
|
|
26
49
|
/** Custom state serializer for stream state objects. Default: JSON with BigInt support. */
|
|
@@ -34,23 +57,64 @@ export interface HttpHandlerOptions {
|
|
|
34
57
|
oauthResourceMetadata?: OAuthResourceMetadata;
|
|
35
58
|
/** Optional dispatch hook for observability (tracing, metrics). */
|
|
36
59
|
dispatchHook?: DispatchHook;
|
|
60
|
+
/** Optional lifecycle hook fired once on the first dispatched request.
|
|
61
|
+
* Mirrors Python's on_serve_start; lazy-firing keeps it fork-safe for
|
|
62
|
+
* pre-fork servers. */
|
|
63
|
+
onServeStart?: ServeStartHook;
|
|
37
64
|
/** Enable HTML landing page at GET {prefix}/. Default: true. */
|
|
38
65
|
enableLandingPage?: boolean;
|
|
39
66
|
/** Enable HTML describe/API reference page at GET {prefix}/describe. Default: true. */
|
|
40
67
|
enableDescribePage?: boolean;
|
|
41
68
|
/** Enable HTML 404 page for unmatched GET routes. Default: true. */
|
|
42
69
|
enableNotFoundPage?: boolean;
|
|
70
|
+
/** Enable JSON health endpoint at GET {prefix}/health. Default: true. */
|
|
71
|
+
enableHealthEndpoint?: boolean;
|
|
43
72
|
/** Protocol name shown in HTML pages. Defaults to the Protocol's name. */
|
|
44
73
|
protocolName?: string;
|
|
74
|
+
/** Operator-supplied protocol-contract version label, surfaced on every
|
|
75
|
+
* access-log record so dashboards and alerts can key off contract
|
|
76
|
+
* changes. Mirrors the Python `RpcServer(..., protocol_version=...)`
|
|
77
|
+
* argument. */
|
|
78
|
+
protocolVersion?: string;
|
|
45
79
|
/** URL to service's source repository, shown in landing/describe pages. */
|
|
46
80
|
repositoryUrl?: string;
|
|
47
81
|
/** External storage config for externalizing large response batches. */
|
|
48
82
|
externalLocation?: ExternalLocationConfig;
|
|
83
|
+
/** Provider for vending pre-signed upload URLs to clients via {prefix}/__upload_url__/init. */
|
|
84
|
+
uploadUrlProvider?: UploadUrlProvider;
|
|
85
|
+
/** Optional advertised maximum upload size, surfaced via VGI-Max-Upload-Bytes. */
|
|
86
|
+
maxUploadBytes?: number;
|
|
87
|
+
/** OAuth scope for PKCE authorization requests. Default: "openid email". */
|
|
88
|
+
oauthPkceScope?: string;
|
|
89
|
+
/** Allowed return-to origins for external frontend redirects. Default: Set(["https://cupola.query-farm.services"]). */
|
|
90
|
+
allowedReturnOrigins?: ReadonlySet<string>;
|
|
91
|
+
|
|
92
|
+
/** Enable opt-in sticky sessions on this HTTP handler. When enabled the
|
|
93
|
+
* server advertises `VGI-Sticky-Enabled: true` (capability discovery),
|
|
94
|
+
* honours `VGI-Session` / `VGI-Session-Accept` headers, and exposes a
|
|
95
|
+
* `DELETE {prefix}/__session__` teardown endpoint. Default: false. */
|
|
96
|
+
enableSticky?: boolean;
|
|
97
|
+
/** Default session TTL in seconds when `ctx.openSession` is called without
|
|
98
|
+
* an explicit `ttl` override. Default: 300. */
|
|
99
|
+
stickyDefaultTtl?: number;
|
|
100
|
+
/** Headers the server emits as `VGI-Echo-<name>: <value>` on the
|
|
101
|
+
* session-opening response. A conformant client captures them and replays
|
|
102
|
+
* them on every subsequent request in the session — used for
|
|
103
|
+
* client-driven routing (e.g. `fly-force-instance-id` on Fly.io). */
|
|
104
|
+
stickyEchoHeaders?: Record<string, string>;
|
|
105
|
+
/** Internal — invoked once at handler creation with a {@link DrainHandle}
|
|
106
|
+
* when sticky is enabled. Conformance fixtures use this to wire up the
|
|
107
|
+
* test-only `/__test_drain__` admin endpoint without the library
|
|
108
|
+
* exposing the registry directly. Production code should hold the
|
|
109
|
+
* handle returned by a future `createHttpHandlerWithDrainHandle` helper. */
|
|
110
|
+
_onStickyHandle?: (handle: import("./sticky.js").DrainHandle) => void;
|
|
49
111
|
}
|
|
50
112
|
|
|
51
113
|
/** Serializer for stream state objects stored in state tokens. */
|
|
52
114
|
export interface StateSerializer {
|
|
115
|
+
/** Encode a stream-state object into the bytes sealed inside a state token. */
|
|
53
116
|
serialize(state: any): Uint8Array;
|
|
117
|
+
/** Decode the bytes recovered from a state token back into a state object. */
|
|
54
118
|
deserialize(bytes: Uint8Array): any;
|
|
55
119
|
}
|
|
56
120
|
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
+
export { AccessLogHook, type AccessLogSink, FdSink } from "./access-log.js";
|
|
4
5
|
export { AuthContext } from "./auth.js";
|
|
5
6
|
export * from "./client/index.js";
|
|
6
7
|
export {
|
|
7
8
|
DESCRIBE_METHOD_NAME,
|
|
8
9
|
DESCRIBE_VERSION,
|
|
9
10
|
DESCRIBE_VERSION_KEY,
|
|
11
|
+
ERROR_KIND_KEY,
|
|
10
12
|
LOG_EXTRA_KEY,
|
|
11
13
|
LOG_LEVEL_KEY,
|
|
12
14
|
LOG_MESSAGE_KEY,
|
|
@@ -14,11 +16,21 @@ export {
|
|
|
14
16
|
REQUEST_ID_KEY,
|
|
15
17
|
REQUEST_VERSION,
|
|
16
18
|
REQUEST_VERSION_KEY,
|
|
19
|
+
RPC_ERROR_HEADER,
|
|
17
20
|
RPC_METHOD_KEY,
|
|
18
21
|
SERVER_ID_KEY,
|
|
19
22
|
STATE_KEY,
|
|
20
23
|
} from "./constants.js";
|
|
21
|
-
export {
|
|
24
|
+
export {
|
|
25
|
+
ERROR_KIND_METHOD_NOT_IMPLEMENTED,
|
|
26
|
+
ERROR_KIND_SERVER_DRAINING,
|
|
27
|
+
ERROR_KIND_SESSION_LOST,
|
|
28
|
+
MethodNotImplementedError,
|
|
29
|
+
RpcError,
|
|
30
|
+
ServerDrainingError,
|
|
31
|
+
SessionLostError,
|
|
32
|
+
VersionError,
|
|
33
|
+
} from "./errors.js";
|
|
22
34
|
export {
|
|
23
35
|
type ExternalLocationConfig,
|
|
24
36
|
type ExternalStorage,
|
|
@@ -54,6 +66,25 @@ export {
|
|
|
54
66
|
type XfccElement,
|
|
55
67
|
type XfccValidateFn,
|
|
56
68
|
} from "./http/index.js";
|
|
69
|
+
export {
|
|
70
|
+
acquireLock,
|
|
71
|
+
computeHash as launcherComputeHash,
|
|
72
|
+
defaultStateDir,
|
|
73
|
+
type FileLockHandle,
|
|
74
|
+
type GcResult,
|
|
75
|
+
gcStateDir,
|
|
76
|
+
type LaunchConfig,
|
|
77
|
+
launch,
|
|
78
|
+
probeSocket,
|
|
79
|
+
type ServeUnixHandle,
|
|
80
|
+
type ServeUnixOptions,
|
|
81
|
+
type SocketPaths,
|
|
82
|
+
type StatusRow,
|
|
83
|
+
serveUnix,
|
|
84
|
+
socketPaths,
|
|
85
|
+
statusRows,
|
|
86
|
+
tryAcquireLock,
|
|
87
|
+
} from "./launcher/index.js";
|
|
57
88
|
export { Protocol } from "./protocol.js";
|
|
58
89
|
export {
|
|
59
90
|
bool,
|
|
@@ -62,10 +93,16 @@ export {
|
|
|
62
93
|
float32,
|
|
63
94
|
inferParamTypes,
|
|
64
95
|
int,
|
|
96
|
+
int8,
|
|
97
|
+
int16,
|
|
65
98
|
int32,
|
|
66
99
|
type SchemaLike,
|
|
67
100
|
str,
|
|
68
101
|
toSchema,
|
|
102
|
+
uint8,
|
|
103
|
+
uint16,
|
|
104
|
+
uint32,
|
|
105
|
+
uint64,
|
|
69
106
|
} from "./schema.js";
|
|
70
107
|
export { VgiRpcServer } from "./server.js";
|
|
71
108
|
export {
|
|
@@ -83,5 +120,7 @@ export {
|
|
|
83
120
|
OutputCollector,
|
|
84
121
|
type ProducerFn,
|
|
85
122
|
type ProducerInit,
|
|
123
|
+
type ServeStartHook,
|
|
124
|
+
TransportKind,
|
|
86
125
|
type UnaryHandler,
|
|
87
126
|
} from "./types.js";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hash a worker tuple (argv + cwd + filtered env) into a deterministic
|
|
6
|
+
* 16-hex-character identifier.
|
|
7
|
+
*
|
|
8
|
+
* Cross-language contract — must match Python's `vgi_rpc.launcher.compute_hash`
|
|
9
|
+
* byte-for-byte so the same worker tuple resolves to the same socket path
|
|
10
|
+
* regardless of which language's launcher discovered it first. The
|
|
11
|
+
* canonical form is:
|
|
12
|
+
*
|
|
13
|
+
* ```python
|
|
14
|
+
* canonical = {
|
|
15
|
+
* "cmd": list(worker_argv),
|
|
16
|
+
* "cwd": cwd if cwd is not None else os.getcwd(),
|
|
17
|
+
* "env": {k: v for k, v in sorted(os.environ.items()) if k.startswith("VGI_RPC_")},
|
|
18
|
+
* }
|
|
19
|
+
* payload = json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
20
|
+
* sha256(payload).hexdigest()[:16]
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* `scripts/regenerate_launcher_parity_vectors.py` in vgi-rpc-python emits a
|
|
24
|
+
* golden vector table; the parity test in `test/launcher.hash.test.ts`
|
|
25
|
+
* asserts byte equality against it.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const HASH_LEN = 16;
|
|
29
|
+
|
|
30
|
+
/** Recursively stringify with sorted object keys and `,`/`:` separators —
|
|
31
|
+
* the JS equivalent of `json.dumps(..., sort_keys=True, separators=(",",":"))`.
|
|
32
|
+
* We can't reuse `JSON.stringify` because the V8 implementation preserves
|
|
33
|
+
* insertion order rather than sorting. */
|
|
34
|
+
function canonicalJson(value: unknown): string {
|
|
35
|
+
if (value === null) return "null";
|
|
36
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
37
|
+
if (typeof value === "number") {
|
|
38
|
+
// Python json emits integers without a trailing `.0` and floats with the
|
|
39
|
+
// shortest round-trippable form. JS `JSON.stringify` matches this for
|
|
40
|
+
// both integer-valued and finite floats; `Infinity`/`NaN` would diverge
|
|
41
|
+
// (Python raises) but they shouldn't occur in launcher payloads.
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
47
|
+
}
|
|
48
|
+
if (typeof value === "object") {
|
|
49
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
50
|
+
const parts = keys.map((k) => `${JSON.stringify(k)}:${canonicalJson((value as Record<string, unknown>)[k])}`);
|
|
51
|
+
return `{${parts.join(",")}}`;
|
|
52
|
+
}
|
|
53
|
+
throw new TypeError(`canonicalJson: unsupported type ${typeof value}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function sha256Hex(data: Uint8Array): Promise<string> {
|
|
57
|
+
// Web Crypto digest accepts BufferSource; copy into a fresh ArrayBuffer
|
|
58
|
+
// to dodge SharedArrayBuffer constraints in some runtimes.
|
|
59
|
+
const buf = new ArrayBuffer(data.byteLength);
|
|
60
|
+
new Uint8Array(buf).set(data);
|
|
61
|
+
const digest = await crypto.subtle.digest("SHA-256", buf);
|
|
62
|
+
return Array.from(new Uint8Array(digest))
|
|
63
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
64
|
+
.join("");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compute the 16-hex-char tuple hash for a worker.
|
|
69
|
+
*
|
|
70
|
+
* @param workerArgv The worker command and its arguments.
|
|
71
|
+
* @param cwd Working directory; defaults to `process.cwd()`.
|
|
72
|
+
* @param env Process environment; defaults to `process.env`. Only
|
|
73
|
+
* keys starting with `VGI_RPC_` participate in the hash —
|
|
74
|
+
* workers that differ only in unrelated env (PATH,
|
|
75
|
+
* HOME, …) intentionally share a worker.
|
|
76
|
+
*/
|
|
77
|
+
export async function computeHash(
|
|
78
|
+
workerArgv: readonly string[],
|
|
79
|
+
cwd?: string,
|
|
80
|
+
env?: Record<string, string | undefined>,
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
const cwdValue = cwd !== undefined ? cwd : process.cwd();
|
|
83
|
+
const sourceEnv = env ?? (process.env as Record<string, string | undefined>);
|
|
84
|
+
|
|
85
|
+
const filteredEnv: Record<string, string> = {};
|
|
86
|
+
for (const key of Object.keys(sourceEnv)) {
|
|
87
|
+
if (key.startsWith("VGI_RPC_")) {
|
|
88
|
+
const v = sourceEnv[key];
|
|
89
|
+
if (v !== undefined) filteredEnv[key] = v;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const canonical = {
|
|
94
|
+
cmd: [...workerArgv],
|
|
95
|
+
cwd: cwdValue,
|
|
96
|
+
env: filteredEnv,
|
|
97
|
+
};
|
|
98
|
+
const payload = new TextEncoder().encode(canonicalJson(canonical));
|
|
99
|
+
const hex = await sha256Hex(payload);
|
|
100
|
+
return hex.slice(0, HASH_LEN);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Exposed for parity-test fixtures that need to inspect the canonical form. */
|
|
104
|
+
export const _internal = { canonicalJson };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AF_UNIX worker launcher — TypeScript port of `vgi_rpc.launcher`.
|
|
6
|
+
*
|
|
7
|
+
* Two halves:
|
|
8
|
+
*
|
|
9
|
+
* - **Coordination** ({@link launch}, {@link computeHash}, {@link gcStateDir},
|
|
10
|
+
* {@link statusRows}): spawn-or-reuse a long-running worker process for a
|
|
11
|
+
* given command tuple, returning the AF_UNIX socket path the caller
|
|
12
|
+
* should connect to. Hash and on-disk layout match the Python
|
|
13
|
+
* implementation byte-for-byte so workers in any language under the
|
|
14
|
+
* same tuple resolve to the same socket.
|
|
15
|
+
*
|
|
16
|
+
* - **Worker runner** ({@link serveUnix}): bind a Unix socket and serve a
|
|
17
|
+
* {@link Protocol} via per-connection IPC streams. Implements the
|
|
18
|
+
* `--unix PATH` / `--idle-timeout SEC` / `UNIX:<path>` contract so
|
|
19
|
+
* launchers (Python or TS) can spawn TS workers transparently.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export { computeHash } from "./hash.js";
|
|
23
|
+
export { type LaunchConfig, launch } from "./launch.js";
|
|
24
|
+
export { acquireLock, type FileLockHandle, tryAcquireLock } from "./lock.js";
|
|
25
|
+
export { type ServeUnixHandle, type ServeUnixOptions, serveUnix } from "./serve-unix.js";
|
|
26
|
+
export {
|
|
27
|
+
defaultStateDir,
|
|
28
|
+
type GcResult,
|
|
29
|
+
gcStateDir,
|
|
30
|
+
probeSocket,
|
|
31
|
+
type SocketPaths,
|
|
32
|
+
type StatusRow,
|
|
33
|
+
socketPaths,
|
|
34
|
+
statusRows,
|
|
35
|
+
} from "./state.js";
|