@query-farm/vgi-rpc 0.4.0 → 0.6.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 +47 -0
- package/dist/client/connect.d.ts.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/oauth.d.ts +36 -0
- package/dist/client/oauth.d.ts.map +1 -1
- package/dist/client/pipe.d.ts +3 -0
- package/dist/client/pipe.d.ts.map +1 -1
- package/dist/client/stream.d.ts +3 -0
- package/dist/client/stream.d.ts.map +1 -1
- package/dist/client/types.d.ts +4 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/constants.d.ts +3 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/dispatch/describe.d.ts.map +1 -1
- package/dist/dispatch/stream.d.ts +2 -1
- package/dist/dispatch/stream.d.ts.map +1 -1
- package/dist/dispatch/unary.d.ts +2 -1
- package/dist/dispatch/unary.d.ts.map +1 -1
- package/dist/external.d.ts +45 -0
- package/dist/external.d.ts.map +1 -0
- package/dist/gcs.d.ts +38 -0
- package/dist/gcs.d.ts.map +1 -0
- package/dist/http/auth.d.ts +13 -2
- package/dist/http/auth.d.ts.map +1 -1
- package/dist/http/bearer.d.ts +34 -0
- package/dist/http/bearer.d.ts.map +1 -0
- package/dist/http/dispatch.d.ts +2 -0
- package/dist/http/dispatch.d.ts.map +1 -1
- package/dist/http/handler.d.ts.map +1 -1
- package/dist/http/index.d.ts +4 -0
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/jwt.d.ts +2 -2
- package/dist/http/jwt.d.ts.map +1 -1
- package/dist/http/mtls.d.ts +78 -0
- package/dist/http/mtls.d.ts.map +1 -0
- package/dist/http/pages.d.ts +9 -0
- package/dist/http/pages.d.ts.map +1 -0
- package/dist/http/types.d.ts +17 -1
- package/dist/http/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1119 -230
- package/dist/index.js.map +24 -20
- package/dist/otel.d.ts +47 -0
- package/dist/otel.d.ts.map +1 -0
- package/dist/s3.d.ts +43 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +44 -1
- package/src/client/connect.ts +13 -5
- package/src/client/index.ts +10 -1
- package/src/client/introspect.ts +1 -1
- package/src/client/oauth.ts +94 -1
- package/src/client/pipe.ts +19 -4
- package/src/client/stream.ts +20 -7
- package/src/client/types.ts +4 -0
- package/src/constants.ts +4 -1
- package/src/dispatch/describe.ts +20 -0
- package/src/dispatch/stream.ts +7 -1
- package/src/dispatch/unary.ts +6 -1
- package/src/external.ts +209 -0
- package/src/gcs.ts +86 -0
- package/src/http/auth.ts +67 -4
- package/src/http/bearer.ts +107 -0
- package/src/http/dispatch.ts +26 -6
- package/src/http/handler.ts +81 -4
- package/src/http/index.ts +10 -0
- package/src/http/jwt.ts +17 -3
- package/src/http/mtls.ts +298 -0
- package/src/http/pages.ts +298 -0
- package/src/http/types.ts +17 -1
- package/src/index.ts +25 -0
- package/src/otel.ts +161 -0
- package/src/s3.ts +94 -0
- package/src/server.ts +42 -8
- package/src/types.ts +34 -0
package/src/dispatch/describe.ts
CHANGED
|
@@ -37,6 +37,8 @@ export const DESCRIBE_SCHEMA = new Schema([
|
|
|
37
37
|
new Field("param_defaults_json", new Utf8(), true),
|
|
38
38
|
new Field("has_header", new Bool(), false),
|
|
39
39
|
new Field("header_schema_ipc", new Binary(), true),
|
|
40
|
+
new Field("is_exchange", new Bool(), true),
|
|
41
|
+
new Field("param_docs_json", new Utf8(), true),
|
|
40
42
|
]);
|
|
41
43
|
|
|
42
44
|
/**
|
|
@@ -60,6 +62,8 @@ export function buildDescribeBatch(
|
|
|
60
62
|
const paramDefaultsJsons: (string | null)[] = [];
|
|
61
63
|
const hasHeaders: boolean[] = [];
|
|
62
64
|
const headerSchemas: (Uint8Array | null)[] = [];
|
|
65
|
+
const isExchanges: (boolean | null)[] = [];
|
|
66
|
+
const paramDocsJsons: (string | null)[] = [];
|
|
63
67
|
|
|
64
68
|
for (const [name, method] of sortedEntries) {
|
|
65
69
|
names.push(name);
|
|
@@ -95,6 +99,18 @@ export function buildDescribeBatch(
|
|
|
95
99
|
|
|
96
100
|
hasHeaders.push(!!method.headerSchema);
|
|
97
101
|
headerSchemas.push(method.headerSchema ? serializeSchema(method.headerSchema) : null);
|
|
102
|
+
|
|
103
|
+
// is_exchange: true for exchange, false for producer, null for unary
|
|
104
|
+
if (method.exchangeFn) {
|
|
105
|
+
isExchanges.push(true);
|
|
106
|
+
} else if (method.producerFn) {
|
|
107
|
+
isExchanges.push(false);
|
|
108
|
+
} else {
|
|
109
|
+
isExchanges.push(null);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// param_docs_json: no docstring source in TypeScript, always null
|
|
113
|
+
paramDocsJsons.push(null);
|
|
98
114
|
}
|
|
99
115
|
|
|
100
116
|
// Build the batch using vectorFromArray for each column
|
|
@@ -108,6 +124,8 @@ export function buildDescribeBatch(
|
|
|
108
124
|
const paramDefaultsArr = vectorFromArray(paramDefaultsJsons, new Utf8());
|
|
109
125
|
const hasHeaderArr = vectorFromArray(hasHeaders, new Bool());
|
|
110
126
|
const headerSchemaArr = vectorFromArray(headerSchemas, new Binary());
|
|
127
|
+
const isExchangeArr = vectorFromArray(isExchanges, new Bool());
|
|
128
|
+
const paramDocsArr = vectorFromArray(paramDocsJsons, new Utf8());
|
|
111
129
|
|
|
112
130
|
const children = [
|
|
113
131
|
nameArr.data[0],
|
|
@@ -120,6 +138,8 @@ export function buildDescribeBatch(
|
|
|
120
138
|
paramDefaultsArr.data[0],
|
|
121
139
|
hasHeaderArr.data[0],
|
|
122
140
|
headerSchemaArr.data[0],
|
|
141
|
+
isExchangeArr.data[0],
|
|
142
|
+
paramDocsArr.data[0],
|
|
123
143
|
];
|
|
124
144
|
|
|
125
145
|
const structType = new Struct(DESCRIBE_SCHEMA.fields);
|
package/src/dispatch/stream.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { Schema } from "@query-farm/apache-arrow";
|
|
5
|
+
import { type ExternalLocationConfig, maybeExternalizeBatch } from "../external.js";
|
|
5
6
|
import type { MethodDefinition } from "../types.js";
|
|
6
7
|
import { OutputCollector } from "../types.js";
|
|
7
8
|
import { conformBatchToSchema } from "../util/conform.js";
|
|
@@ -33,6 +34,7 @@ export async function dispatchStream(
|
|
|
33
34
|
reader: IpcStreamReader,
|
|
34
35
|
serverId: string,
|
|
35
36
|
requestId: string | null,
|
|
37
|
+
externalConfig?: ExternalLocationConfig,
|
|
36
38
|
): Promise<void> {
|
|
37
39
|
const isProducer = !!method.producerFn;
|
|
38
40
|
|
|
@@ -133,7 +135,11 @@ export async function dispatchStream(
|
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
for (const emitted of out.batches) {
|
|
136
|
-
|
|
138
|
+
let batch = emitted.batch;
|
|
139
|
+
if (externalConfig) {
|
|
140
|
+
batch = await maybeExternalizeBatch(batch, externalConfig);
|
|
141
|
+
}
|
|
142
|
+
stream.write(batch);
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
if (out.finished) {
|
package/src/dispatch/unary.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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, maybeExternalizeBatch } from "../external.js";
|
|
4
5
|
import type { MethodDefinition } from "../types.js";
|
|
5
6
|
import { OutputCollector } from "../types.js";
|
|
6
7
|
import { buildErrorBatch, buildResultBatch } from "../wire/response.js";
|
|
@@ -17,13 +18,17 @@ export async function dispatchUnary(
|
|
|
17
18
|
writer: IpcStreamWriter,
|
|
18
19
|
serverId: string,
|
|
19
20
|
requestId: string | null,
|
|
21
|
+
externalConfig?: ExternalLocationConfig,
|
|
20
22
|
): Promise<void> {
|
|
21
23
|
const schema = method.resultSchema;
|
|
22
24
|
const out = new OutputCollector(schema, true, serverId, requestId);
|
|
23
25
|
|
|
24
26
|
try {
|
|
25
27
|
const result = await method.handler!(params, out);
|
|
26
|
-
|
|
28
|
+
let resultBatch = buildResultBatch(schema, result, serverId, requestId);
|
|
29
|
+
if (externalConfig) {
|
|
30
|
+
resultBatch = await maybeExternalizeBatch(resultBatch, externalConfig);
|
|
31
|
+
}
|
|
27
32
|
// Collect log batches (from clientLog) + result batch
|
|
28
33
|
const batches = [...out.batches.map((b) => b.batch), resultBatch];
|
|
29
34
|
writer.writeStream(schema, batches);
|
package/src/external.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* External storage support for large Arrow IPC batches.
|
|
6
|
+
*
|
|
7
|
+
* When a batch exceeds a configurable threshold, it is serialized to IPC,
|
|
8
|
+
* optionally compressed with zstd, and uploaded to pluggable storage.
|
|
9
|
+
* The batch is replaced with a zero-row "pointer batch" containing the
|
|
10
|
+
* download URL and SHA-256 checksum in metadata.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { type RecordBatch, RecordBatchReader, RecordBatchStreamWriter, type Schema } from "@query-farm/apache-arrow";
|
|
14
|
+
import { LOCATION_KEY, LOCATION_SHA256_KEY, LOG_LEVEL_KEY } from "./constants.js";
|
|
15
|
+
import { zstdCompress, zstdDecompress } from "./util/zstd.js";
|
|
16
|
+
import { buildEmptyBatch } from "./wire/response.js";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Interfaces and configuration
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Pluggable storage backend for uploading large batches. */
|
|
23
|
+
export interface ExternalStorage {
|
|
24
|
+
/** Upload IPC data and return a URL for retrieval. */
|
|
25
|
+
upload(data: Uint8Array, contentEncoding: string): Promise<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Configuration for external storage of large batches. */
|
|
29
|
+
export interface ExternalLocationConfig {
|
|
30
|
+
/** Storage backend for uploading. */
|
|
31
|
+
storage: ExternalStorage;
|
|
32
|
+
/** Minimum batch byte size to trigger externalization. Default: 1MB. */
|
|
33
|
+
externalizeThresholdBytes?: number;
|
|
34
|
+
/** Optional zstd compression for uploaded data. */
|
|
35
|
+
compression?: { algorithm: "zstd"; level?: number };
|
|
36
|
+
/** URL validator called before fetching. Throw to reject. Default: HTTPS-only. */
|
|
37
|
+
urlValidator?: ((url: string) => void) | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_THRESHOLD = 1_048_576; // 1 MB
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// URL validation
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** Default validator that rejects non-HTTPS URLs. */
|
|
47
|
+
export function httpsOnlyValidator(url: string): void {
|
|
48
|
+
const parsed = new URL(url);
|
|
49
|
+
if (parsed.protocol !== "https:") {
|
|
50
|
+
throw new Error(`External location URL must use HTTPS, got "${parsed.protocol}"`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// SHA-256 helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
async function sha256Hex(data: Uint8Array): Promise<string> {
|
|
59
|
+
// Copy to a plain ArrayBuffer to satisfy Web Crypto API type requirements
|
|
60
|
+
const buf = new ArrayBuffer(data.byteLength);
|
|
61
|
+
new Uint8Array(buf).set(data);
|
|
62
|
+
const hash = await crypto.subtle.digest("SHA-256", buf);
|
|
63
|
+
return Array.from(new Uint8Array(hash))
|
|
64
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
65
|
+
.join("");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Detection
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/** Returns true if the batch is a zero-row pointer to external data. */
|
|
73
|
+
export function isExternalLocationBatch(batch: RecordBatch): boolean {
|
|
74
|
+
if (batch.numRows !== 0) return false;
|
|
75
|
+
const meta = batch.metadata;
|
|
76
|
+
if (!meta) return false;
|
|
77
|
+
return meta.has(LOCATION_KEY) && !meta.has(LOG_LEVEL_KEY);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Pointer batch creation
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/** Create a zero-row pointer batch with location URL and optional SHA-256. */
|
|
85
|
+
export function makeExternalLocationBatch(schema: Schema, url: string, sha256?: string): RecordBatch {
|
|
86
|
+
const metadata = new Map<string, string>();
|
|
87
|
+
metadata.set(LOCATION_KEY, url);
|
|
88
|
+
if (sha256) {
|
|
89
|
+
metadata.set(LOCATION_SHA256_KEY, sha256);
|
|
90
|
+
}
|
|
91
|
+
return buildEmptyBatch(schema, metadata);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// IPC serialization helpers
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
function serializeBatchToIpc(batch: RecordBatch): Uint8Array {
|
|
99
|
+
const writer = new RecordBatchStreamWriter();
|
|
100
|
+
writer.reset(undefined, batch.schema);
|
|
101
|
+
writer.write(batch);
|
|
102
|
+
writer.close();
|
|
103
|
+
return writer.toUint8Array(true);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function batchByteSize(batch: RecordBatch): number {
|
|
107
|
+
// Arrow TS data.byteLength doesn't reflect actual data size.
|
|
108
|
+
// Estimate from IPC serialization size for threshold check.
|
|
109
|
+
const writer = new RecordBatchStreamWriter();
|
|
110
|
+
writer.reset(undefined, batch.schema);
|
|
111
|
+
writer.write(batch);
|
|
112
|
+
writer.close();
|
|
113
|
+
return writer.toUint8Array(true).byteLength;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Write path: externalization
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Maybe externalize a batch if it exceeds the threshold.
|
|
122
|
+
* Returns the original batch unchanged if below threshold or no config.
|
|
123
|
+
*/
|
|
124
|
+
export async function maybeExternalizeBatch(
|
|
125
|
+
batch: RecordBatch,
|
|
126
|
+
config?: ExternalLocationConfig | null,
|
|
127
|
+
): Promise<RecordBatch> {
|
|
128
|
+
if (!config?.storage) return batch;
|
|
129
|
+
if (batch.numRows === 0) return batch;
|
|
130
|
+
|
|
131
|
+
const threshold = config.externalizeThresholdBytes ?? DEFAULT_THRESHOLD;
|
|
132
|
+
if (batchByteSize(batch) < threshold) return batch;
|
|
133
|
+
|
|
134
|
+
// Serialize to IPC
|
|
135
|
+
let ipcData = serializeBatchToIpc(batch);
|
|
136
|
+
|
|
137
|
+
// Compute SHA-256 of raw IPC bytes (pre-compression)
|
|
138
|
+
const checksum = await sha256Hex(ipcData);
|
|
139
|
+
|
|
140
|
+
// Optionally compress
|
|
141
|
+
let contentEncoding = "";
|
|
142
|
+
if (config.compression?.algorithm === "zstd") {
|
|
143
|
+
ipcData = zstdCompress(ipcData, config.compression.level ?? 3) as Uint8Array;
|
|
144
|
+
contentEncoding = "zstd";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Upload
|
|
148
|
+
const url = await config.storage.upload(ipcData, contentEncoding);
|
|
149
|
+
|
|
150
|
+
// Return pointer batch
|
|
151
|
+
return makeExternalLocationBatch(batch.schema, url, checksum);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Read path: resolution
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve an external pointer batch by fetching the data from the URL.
|
|
160
|
+
* Returns the original batch unchanged if not a pointer or no config.
|
|
161
|
+
*/
|
|
162
|
+
export async function resolveExternalLocation(
|
|
163
|
+
batch: RecordBatch,
|
|
164
|
+
config?: ExternalLocationConfig | null,
|
|
165
|
+
): Promise<RecordBatch> {
|
|
166
|
+
if (!config) return batch;
|
|
167
|
+
if (!isExternalLocationBatch(batch)) return batch;
|
|
168
|
+
|
|
169
|
+
const url = batch.metadata?.get(LOCATION_KEY);
|
|
170
|
+
if (!url) return batch;
|
|
171
|
+
|
|
172
|
+
// Validate URL
|
|
173
|
+
const validator = config.urlValidator === null ? undefined : (config.urlValidator ?? httpsOnlyValidator);
|
|
174
|
+
if (validator) {
|
|
175
|
+
validator(url);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Fetch
|
|
179
|
+
const response = await fetch(url);
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
throw new Error(`External location fetch failed: ${response.status} ${response.statusText} [url: ${url}]`);
|
|
182
|
+
}
|
|
183
|
+
let data = new Uint8Array(await response.arrayBuffer());
|
|
184
|
+
|
|
185
|
+
// Decompress if needed
|
|
186
|
+
const contentEncoding = response.headers.get("Content-Encoding");
|
|
187
|
+
if (contentEncoding === "zstd") {
|
|
188
|
+
data = new Uint8Array(zstdDecompress(data));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Verify SHA-256 if present
|
|
192
|
+
const expectedSha256 = batch.metadata?.get(LOCATION_SHA256_KEY);
|
|
193
|
+
if (expectedSha256) {
|
|
194
|
+
const actualSha256 = await sha256Hex(data);
|
|
195
|
+
if (actualSha256 !== expectedSha256) {
|
|
196
|
+
throw new Error(`SHA-256 checksum mismatch for ${url}: expected ${expectedSha256}, got ${actualSha256}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Parse IPC stream
|
|
201
|
+
const reader = await RecordBatchReader.from(data);
|
|
202
|
+
await reader.open();
|
|
203
|
+
const resolved = reader.next();
|
|
204
|
+
if (!resolved || resolved.done || !resolved.value) {
|
|
205
|
+
throw new Error(`No data batch found in external IPC stream from ${url}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return resolved.value;
|
|
209
|
+
}
|
package/src/gcs.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Google Cloud Storage backend for external storage of large Arrow IPC batches.
|
|
6
|
+
*
|
|
7
|
+
* Requires `@google-cloud/storage` as a peer dependency.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { createGCSStorage } from "@query-farm/vgi-rpc/gcs";
|
|
12
|
+
*
|
|
13
|
+
* const storage = createGCSStorage({
|
|
14
|
+
* bucket: "my-bucket",
|
|
15
|
+
* prefix: "vgi-rpc/",
|
|
16
|
+
* });
|
|
17
|
+
* const handler = createHttpHandler(protocol, {
|
|
18
|
+
* externalLocation: { storage, externalizeThresholdBytes: 1_048_576 },
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ExternalStorage } from "./external.js";
|
|
24
|
+
|
|
25
|
+
/** Configuration for the GCS storage backend. */
|
|
26
|
+
export interface GCSStorageConfig {
|
|
27
|
+
/** GCS bucket name. */
|
|
28
|
+
bucket: string;
|
|
29
|
+
/** Key prefix for uploaded objects. Default: "vgi-rpc/". */
|
|
30
|
+
prefix?: string;
|
|
31
|
+
/** Lifetime of signed GET URLs in seconds. Default: 3600 (1 hour). */
|
|
32
|
+
presignExpirySeconds?: number;
|
|
33
|
+
/** GCS project ID. If omitted, uses Application Default Credentials. */
|
|
34
|
+
projectId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a GCS-backed ExternalStorage.
|
|
39
|
+
*
|
|
40
|
+
* Lazily imports `@google-cloud/storage` on first upload to avoid
|
|
41
|
+
* loading the SDK unless needed.
|
|
42
|
+
*/
|
|
43
|
+
export function createGCSStorage(config: GCSStorageConfig): ExternalStorage {
|
|
44
|
+
const bucket = config.bucket;
|
|
45
|
+
const prefix = config.prefix ?? "vgi-rpc/";
|
|
46
|
+
const presignExpiry = config.presignExpirySeconds ?? 3600;
|
|
47
|
+
|
|
48
|
+
let storageClient: any = null;
|
|
49
|
+
|
|
50
|
+
async function ensureClient(): Promise<any> {
|
|
51
|
+
if (storageClient) return storageClient;
|
|
52
|
+
const { Storage } = await import("@google-cloud/storage");
|
|
53
|
+
const clientConfig: Record<string, any> = {};
|
|
54
|
+
if (config.projectId) clientConfig.projectId = config.projectId;
|
|
55
|
+
storageClient = new Storage(clientConfig);
|
|
56
|
+
return storageClient;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
async upload(data: Uint8Array, contentEncoding: string): Promise<string> {
|
|
61
|
+
const client = await ensureClient();
|
|
62
|
+
const bucketRef = client.bucket(bucket);
|
|
63
|
+
const blobName = `${prefix}${crypto.randomUUID()}${contentEncoding === "zstd" ? ".arrow.zst" : ".arrow"}`;
|
|
64
|
+
const blob = bucketRef.file(blobName);
|
|
65
|
+
|
|
66
|
+
const options: Record<string, any> = {
|
|
67
|
+
contentType: "application/vnd.apache.arrow.stream",
|
|
68
|
+
resumable: false,
|
|
69
|
+
};
|
|
70
|
+
if (contentEncoding) {
|
|
71
|
+
options.metadata = { contentEncoding };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await blob.save(Buffer.from(data), options);
|
|
75
|
+
|
|
76
|
+
// Generate signed GET URL
|
|
77
|
+
const [url] = await blob.getSignedUrl({
|
|
78
|
+
version: "v4" as const,
|
|
79
|
+
action: "read" as const,
|
|
80
|
+
expires: Date.now() + presignExpiry * 1000,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return url;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/http/auth.ts
CHANGED
|
@@ -12,10 +12,21 @@ export interface OAuthResourceMetadata {
|
|
|
12
12
|
authorizationServers: string[];
|
|
13
13
|
scopesSupported?: string[];
|
|
14
14
|
bearerMethodsSupported?: string[];
|
|
15
|
+
resourceSigningAlgValuesSupported?: string[];
|
|
15
16
|
resourceName?: string;
|
|
16
17
|
resourceDocumentation?: string;
|
|
17
18
|
resourcePolicyUri?: string;
|
|
18
19
|
resourceTosUri?: string;
|
|
20
|
+
/** OAuth client_id that clients should use with the authorization server. */
|
|
21
|
+
clientId?: string;
|
|
22
|
+
/** OAuth client_secret that clients should use with the authorization server. */
|
|
23
|
+
clientSecret?: string;
|
|
24
|
+
/** OAuth client_id for device code flow. */
|
|
25
|
+
deviceCodeClientId?: string;
|
|
26
|
+
/** OAuth client_secret for device code flow. */
|
|
27
|
+
deviceCodeClientSecret?: string;
|
|
28
|
+
/** When true, clients should use the OIDC id_token as the Bearer token instead of access_token. */
|
|
29
|
+
useIdTokenAsBearer?: boolean;
|
|
19
30
|
}
|
|
20
31
|
|
|
21
32
|
/** Convert OAuthResourceMetadata to RFC 9728 snake_case JSON object. */
|
|
@@ -26,10 +37,39 @@ export function oauthResourceMetadataToJson(metadata: OAuthResourceMetadata): Re
|
|
|
26
37
|
};
|
|
27
38
|
if (metadata.scopesSupported) json.scopes_supported = metadata.scopesSupported;
|
|
28
39
|
if (metadata.bearerMethodsSupported) json.bearer_methods_supported = metadata.bearerMethodsSupported;
|
|
40
|
+
if (metadata.resourceSigningAlgValuesSupported)
|
|
41
|
+
json.resource_signing_alg_values_supported = metadata.resourceSigningAlgValuesSupported;
|
|
29
42
|
if (metadata.resourceName) json.resource_name = metadata.resourceName;
|
|
30
43
|
if (metadata.resourceDocumentation) json.resource_documentation = metadata.resourceDocumentation;
|
|
31
44
|
if (metadata.resourcePolicyUri) json.resource_policy_uri = metadata.resourcePolicyUri;
|
|
32
45
|
if (metadata.resourceTosUri) json.resource_tos_uri = metadata.resourceTosUri;
|
|
46
|
+
if (metadata.clientId) {
|
|
47
|
+
if (!/^[A-Za-z0-9\-._~]+$/.test(metadata.clientId)) {
|
|
48
|
+
throw new Error(`Invalid client_id: must contain only URL-safe characters [A-Za-z0-9\\-._~]`);
|
|
49
|
+
}
|
|
50
|
+
json.client_id = metadata.clientId;
|
|
51
|
+
}
|
|
52
|
+
if (metadata.clientSecret) {
|
|
53
|
+
if (!/^[A-Za-z0-9\-._~]+$/.test(metadata.clientSecret)) {
|
|
54
|
+
throw new Error(`Invalid client_secret: must contain only URL-safe characters [A-Za-z0-9\\-._~]`);
|
|
55
|
+
}
|
|
56
|
+
json.client_secret = metadata.clientSecret;
|
|
57
|
+
}
|
|
58
|
+
if (metadata.deviceCodeClientId) {
|
|
59
|
+
if (!/^[A-Za-z0-9\-._~]+$/.test(metadata.deviceCodeClientId)) {
|
|
60
|
+
throw new Error(`Invalid device_code_client_id: must contain only URL-safe characters [A-Za-z0-9\\-._~]`);
|
|
61
|
+
}
|
|
62
|
+
json.device_code_client_id = metadata.deviceCodeClientId;
|
|
63
|
+
}
|
|
64
|
+
if (metadata.deviceCodeClientSecret) {
|
|
65
|
+
if (!/^[A-Za-z0-9\-._~]+$/.test(metadata.deviceCodeClientSecret)) {
|
|
66
|
+
throw new Error(`Invalid device_code_client_secret: must contain only URL-safe characters [A-Za-z0-9\\-._~]`);
|
|
67
|
+
}
|
|
68
|
+
json.device_code_client_secret = metadata.deviceCodeClientSecret;
|
|
69
|
+
}
|
|
70
|
+
if (metadata.useIdTokenAsBearer) {
|
|
71
|
+
json.use_id_token_as_bearer = true;
|
|
72
|
+
}
|
|
33
73
|
return json;
|
|
34
74
|
}
|
|
35
75
|
|
|
@@ -38,10 +78,33 @@ export function wellKnownPath(prefix: string): string {
|
|
|
38
78
|
return `/.well-known/oauth-protected-resource${prefix}`;
|
|
39
79
|
}
|
|
40
80
|
|
|
41
|
-
/** Build a WWW-Authenticate header value with optional resource_metadata URL. */
|
|
42
|
-
export function buildWwwAuthenticateHeader(
|
|
81
|
+
/** Build a WWW-Authenticate header value with optional resource_metadata URL, client_id, client_secret, device_code_client_id, device_code_client_secret, and use_id_token_as_bearer. */
|
|
82
|
+
export function buildWwwAuthenticateHeader(
|
|
83
|
+
metadataUrl?: string,
|
|
84
|
+
clientId?: string,
|
|
85
|
+
clientSecret?: string,
|
|
86
|
+
useIdTokenAsBearer?: boolean,
|
|
87
|
+
deviceCodeClientId?: string,
|
|
88
|
+
deviceCodeClientSecret?: string,
|
|
89
|
+
): string {
|
|
90
|
+
let header = "Bearer";
|
|
43
91
|
if (metadataUrl) {
|
|
44
|
-
|
|
92
|
+
header += ` resource_metadata="${metadataUrl}"`;
|
|
93
|
+
}
|
|
94
|
+
if (clientId) {
|
|
95
|
+
header += `, client_id="${clientId}"`;
|
|
96
|
+
}
|
|
97
|
+
if (clientSecret) {
|
|
98
|
+
header += `, client_secret="${clientSecret}"`;
|
|
99
|
+
}
|
|
100
|
+
if (deviceCodeClientId) {
|
|
101
|
+
header += `, device_code_client_id="${deviceCodeClientId}"`;
|
|
102
|
+
}
|
|
103
|
+
if (deviceCodeClientSecret) {
|
|
104
|
+
header += `, device_code_client_secret="${deviceCodeClientSecret}"`;
|
|
105
|
+
}
|
|
106
|
+
if (useIdTokenAsBearer) {
|
|
107
|
+
header += `, use_id_token_as_bearer="true"`;
|
|
45
108
|
}
|
|
46
|
-
return
|
|
109
|
+
return header;
|
|
47
110
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { timingSafeEqual } from "node:crypto";
|
|
5
|
+
import type { AuthContext } from "../auth.js";
|
|
6
|
+
import type { AuthenticateFn } from "./auth.js";
|
|
7
|
+
|
|
8
|
+
/** Receives the raw bearer token string, returns an AuthContext on success. Must throw on failure. */
|
|
9
|
+
export type BearerValidateFn = (token: string) => AuthContext | Promise<AuthContext>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a bearer-token authenticate callback.
|
|
13
|
+
*
|
|
14
|
+
* Extracts the `Authorization: Bearer <token>` header and delegates
|
|
15
|
+
* validation to the user-supplied `validate` callback.
|
|
16
|
+
*/
|
|
17
|
+
export function bearerAuthenticate(options: { validate: BearerValidateFn }): AuthenticateFn {
|
|
18
|
+
const { validate } = options;
|
|
19
|
+
|
|
20
|
+
return async function authenticate(request: Request): Promise<AuthContext> {
|
|
21
|
+
const authHeader = request.headers.get("Authorization") ?? "";
|
|
22
|
+
if (!authHeader.startsWith("Bearer ")) {
|
|
23
|
+
throw new Error("Missing or invalid Authorization header");
|
|
24
|
+
}
|
|
25
|
+
const token = authHeader.slice(7);
|
|
26
|
+
return validate(token);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Constant-time string comparison to prevent timing attacks on token lookup. */
|
|
31
|
+
function safeEqual(a: string, b: string): boolean {
|
|
32
|
+
const enc = new TextEncoder();
|
|
33
|
+
const bufA = enc.encode(a);
|
|
34
|
+
const bufB = enc.encode(b);
|
|
35
|
+
if (bufA.byteLength !== bufB.byteLength) return false;
|
|
36
|
+
return timingSafeEqual(bufA, bufB);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a bearer-token authenticate callback from a static token map.
|
|
41
|
+
*
|
|
42
|
+
* Convenience wrapper around `bearerAuthenticate` that looks up the
|
|
43
|
+
* token in a pre-built mapping using constant-time comparison.
|
|
44
|
+
*/
|
|
45
|
+
export function bearerAuthenticateStatic(options: {
|
|
46
|
+
tokens: ReadonlyMap<string, AuthContext> | Record<string, AuthContext>;
|
|
47
|
+
}): AuthenticateFn {
|
|
48
|
+
const entries: [string, AuthContext][] =
|
|
49
|
+
options.tokens instanceof Map ? [...options.tokens.entries()] : Object.entries(options.tokens);
|
|
50
|
+
|
|
51
|
+
function validate(token: string): AuthContext {
|
|
52
|
+
for (const [key, ctx] of entries) {
|
|
53
|
+
if (safeEqual(token, key)) return ctx;
|
|
54
|
+
}
|
|
55
|
+
throw new Error("Unknown bearer token");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return bearerAuthenticate({ validate });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check whether an error represents a credential rejection (should be
|
|
63
|
+
* caught by the chain) vs a bug or authorization failure (should propagate).
|
|
64
|
+
*
|
|
65
|
+
* Mirrors Python's semantics where only `ValueError` is caught:
|
|
66
|
+
* - Plain `Error` (constructor === Error) without `PermissionError` name → credential rejection
|
|
67
|
+
* - `TypeError`, `RangeError`, etc. (Error subclasses) → bug, propagate
|
|
68
|
+
* - `PermissionError` name → authorization failure, propagate
|
|
69
|
+
* - Non-Error throws → propagate
|
|
70
|
+
*/
|
|
71
|
+
function isCredentialError(err: unknown): err is Error {
|
|
72
|
+
return err instanceof Error && err.constructor === Error && err.name !== "PermissionError";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Chain multiple authenticate callbacks, trying each in order.
|
|
77
|
+
*
|
|
78
|
+
* Each authenticator is called in sequence. Plain `Error` (credential
|
|
79
|
+
* rejection) causes the next authenticator to be tried. Error subclasses
|
|
80
|
+
* (`TypeError`, `RangeError`, etc.), `PermissionError`-named errors, and
|
|
81
|
+
* non-Error throws propagate immediately.
|
|
82
|
+
*
|
|
83
|
+
* @throws Error if no authenticators are provided.
|
|
84
|
+
*/
|
|
85
|
+
export function chainAuthenticate(...authenticators: AuthenticateFn[]): AuthenticateFn {
|
|
86
|
+
if (authenticators.length === 0) {
|
|
87
|
+
throw new Error("chainAuthenticate requires at least one authenticator");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return async function authenticate(request: Request): Promise<AuthContext> {
|
|
91
|
+
let lastError: Error | null = null;
|
|
92
|
+
for (const authFn of authenticators) {
|
|
93
|
+
try {
|
|
94
|
+
return await authFn(request);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (isCredentialError(err)) {
|
|
97
|
+
lastError = err;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const error = new Error("No authenticator accepted the request");
|
|
104
|
+
if (lastError) error.cause = lastError;
|
|
105
|
+
throw error;
|
|
106
|
+
};
|
|
107
|
+
}
|