@query-farm/vgi-rpc 0.3.4 → 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/auth.d.ts +13 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/client/connect.d.ts.map +1 -1
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/introspect.d.ts +1 -0
- package/dist/client/introspect.d.ts.map +1 -1
- package/dist/client/oauth.d.ts +62 -0
- package/dist/client/oauth.d.ts.map +1 -0
- package/dist/client/pipe.d.ts +3 -0
- package/dist/client/pipe.d.ts.map +1 -1
- package/dist/client/stream.d.ts +5 -0
- package/dist/client/stream.d.ts.map +1 -1
- package/dist/client/types.d.ts +6 -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 +32 -0
- package/dist/http/auth.d.ts.map +1 -0
- package/dist/http/bearer.d.ts +34 -0
- package/dist/http/bearer.d.ts.map +1 -0
- package/dist/http/dispatch.d.ts +4 -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 +8 -0
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/jwt.d.ts +21 -0
- package/dist/http/jwt.d.ts.map +1 -0
- 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 +22 -1
- package/dist/http/types.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2576 -317
- package/dist/index.js.map +27 -18
- 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 +38 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/wire/response.d.ts.map +1 -1
- package/package.json +46 -2
- package/src/auth.ts +31 -0
- package/src/client/connect.ts +28 -6
- package/src/client/index.ts +11 -0
- package/src/client/introspect.ts +15 -3
- package/src/client/oauth.ts +167 -0
- package/src/client/pipe.ts +19 -4
- package/src/client/stream.ts +32 -7
- package/src/client/types.ts +6 -0
- package/src/constants.ts +4 -1
- package/src/dispatch/describe.ts +20 -0
- package/src/dispatch/stream.ts +18 -4
- package/src/dispatch/unary.ts +6 -1
- package/src/external.ts +209 -0
- package/src/gcs.ts +86 -0
- package/src/http/auth.ts +110 -0
- package/src/http/bearer.ts +107 -0
- package/src/http/dispatch.ts +32 -10
- package/src/http/handler.ts +120 -3
- package/src/http/index.ts +14 -0
- package/src/http/jwt.ts +80 -0
- package/src/http/mtls.ts +298 -0
- package/src/http/pages.ts +298 -0
- package/src/http/types.ts +23 -1
- package/src/index.ts +32 -0
- package/src/otel.ts +161 -0
- package/src/s3.ts +94 -0
- package/src/server.ts +42 -8
- package/src/types.ts +51 -3
- package/src/wire/response.ts +28 -14
package/src/http/auth.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { AuthContext } from "../auth.js";
|
|
5
|
+
|
|
6
|
+
/** Async function that authenticates an incoming HTTP request. */
|
|
7
|
+
export type AuthenticateFn = (request: Request) => AuthContext | Promise<AuthContext>;
|
|
8
|
+
|
|
9
|
+
/** RFC 9728 OAuth Protected Resource Metadata. */
|
|
10
|
+
export interface OAuthResourceMetadata {
|
|
11
|
+
resource: string;
|
|
12
|
+
authorizationServers: string[];
|
|
13
|
+
scopesSupported?: string[];
|
|
14
|
+
bearerMethodsSupported?: string[];
|
|
15
|
+
resourceSigningAlgValuesSupported?: string[];
|
|
16
|
+
resourceName?: string;
|
|
17
|
+
resourceDocumentation?: string;
|
|
18
|
+
resourcePolicyUri?: string;
|
|
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;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Convert OAuthResourceMetadata to RFC 9728 snake_case JSON object. */
|
|
33
|
+
export function oauthResourceMetadataToJson(metadata: OAuthResourceMetadata): Record<string, any> {
|
|
34
|
+
const json: Record<string, any> = {
|
|
35
|
+
resource: metadata.resource,
|
|
36
|
+
authorization_servers: metadata.authorizationServers,
|
|
37
|
+
};
|
|
38
|
+
if (metadata.scopesSupported) json.scopes_supported = metadata.scopesSupported;
|
|
39
|
+
if (metadata.bearerMethodsSupported) json.bearer_methods_supported = metadata.bearerMethodsSupported;
|
|
40
|
+
if (metadata.resourceSigningAlgValuesSupported)
|
|
41
|
+
json.resource_signing_alg_values_supported = metadata.resourceSigningAlgValuesSupported;
|
|
42
|
+
if (metadata.resourceName) json.resource_name = metadata.resourceName;
|
|
43
|
+
if (metadata.resourceDocumentation) json.resource_documentation = metadata.resourceDocumentation;
|
|
44
|
+
if (metadata.resourcePolicyUri) json.resource_policy_uri = metadata.resourcePolicyUri;
|
|
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
|
+
}
|
|
73
|
+
return json;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Compute the well-known path for OAuth Protected Resource Metadata. */
|
|
77
|
+
export function wellKnownPath(prefix: string): string {
|
|
78
|
+
return `/.well-known/oauth-protected-resource${prefix}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
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";
|
|
91
|
+
if (metadataUrl) {
|
|
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"`;
|
|
108
|
+
}
|
|
109
|
+
return header;
|
|
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
|
+
}
|
package/src/http/dispatch.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { RecordBatch, RecordBatchReader, Schema } from "@query-farm/apache-arrow";
|
|
5
|
+
import type { AuthContext } from "../auth.js";
|
|
5
6
|
import { STATE_KEY } from "../constants.js";
|
|
6
7
|
import { buildDescribeBatch, DESCRIBE_SCHEMA } from "../dispatch/describe.js";
|
|
8
|
+
import { type ExternalLocationConfig, maybeExternalizeBatch } from "../external.js";
|
|
7
9
|
import type { MethodDefinition } from "../types.js";
|
|
8
10
|
import { OutputCollector } from "../types.js";
|
|
9
11
|
import { conformBatchToSchema } from "../util/conform.js";
|
|
@@ -28,6 +30,8 @@ export interface DispatchContext {
|
|
|
28
30
|
serverId: string;
|
|
29
31
|
maxStreamResponseBytes?: number;
|
|
30
32
|
stateSerializer: StateSerializer;
|
|
33
|
+
authContext?: AuthContext;
|
|
34
|
+
externalLocation?: ExternalLocationConfig;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/** Dispatch a __describe__ request. */
|
|
@@ -55,16 +59,22 @@ export async function httpDispatchUnary(
|
|
|
55
59
|
throw new HttpRpcError(`Method name in request '${parsed.methodName}' does not match URL '${method.name}'`, 400);
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
const out = new OutputCollector(schema, true, ctx.serverId, parsed.requestId);
|
|
62
|
+
const out = new OutputCollector(schema, true, ctx.serverId, parsed.requestId, ctx.authContext);
|
|
59
63
|
|
|
60
64
|
try {
|
|
61
65
|
const result = await method.handler!(parsed.params, out);
|
|
62
|
-
|
|
66
|
+
let resultBatch = buildResultBatch(schema, result, ctx.serverId, parsed.requestId);
|
|
67
|
+
if (ctx.externalLocation) {
|
|
68
|
+
resultBatch = await maybeExternalizeBatch(resultBatch, ctx.externalLocation);
|
|
69
|
+
}
|
|
63
70
|
const batches = [...out.batches.map((b) => b.batch), resultBatch];
|
|
64
71
|
return arrowResponse(serializeIpcStream(schema, batches));
|
|
65
72
|
} catch (error: any) {
|
|
66
73
|
const errBatch = buildErrorBatch(schema, error, ctx.serverId, parsed.requestId);
|
|
67
|
-
|
|
74
|
+
const response = arrowResponse(serializeIpcStream(schema, [errBatch]), 500);
|
|
75
|
+
// Attach the error so the dispatch hook can see it
|
|
76
|
+
(response as any).__dispatchError = error;
|
|
77
|
+
return response;
|
|
68
78
|
}
|
|
69
79
|
}
|
|
70
80
|
|
|
@@ -96,7 +106,9 @@ export async function httpDispatchStreamInit(
|
|
|
96
106
|
} catch (error: any) {
|
|
97
107
|
const errSchema = method.headerSchema ?? EMPTY_SCHEMA;
|
|
98
108
|
const errBatch = buildErrorBatch(errSchema, error, ctx.serverId, parsed.requestId);
|
|
99
|
-
|
|
109
|
+
const response = arrowResponse(serializeIpcStream(errSchema, [errBatch]), 500);
|
|
110
|
+
(response as any).__dispatchError = error;
|
|
111
|
+
return response;
|
|
100
112
|
}
|
|
101
113
|
|
|
102
114
|
// Support dynamic output schemas (same as pipe transport)
|
|
@@ -107,14 +119,16 @@ export async function httpDispatchStreamInit(
|
|
|
107
119
|
let headerBytes: Uint8Array | null = null;
|
|
108
120
|
if (method.headerSchema && method.headerInit) {
|
|
109
121
|
try {
|
|
110
|
-
const headerOut = new OutputCollector(method.headerSchema, true, ctx.serverId, parsed.requestId);
|
|
122
|
+
const headerOut = new OutputCollector(method.headerSchema, true, ctx.serverId, parsed.requestId, ctx.authContext);
|
|
111
123
|
const headerValues = method.headerInit(parsed.params, state, headerOut);
|
|
112
124
|
const headerBatch = buildResultBatch(method.headerSchema, headerValues, ctx.serverId, parsed.requestId);
|
|
113
125
|
const headerBatches = [...headerOut.batches.map((b) => b.batch), headerBatch];
|
|
114
126
|
headerBytes = serializeIpcStream(method.headerSchema, headerBatches);
|
|
115
127
|
} catch (error: any) {
|
|
116
128
|
const errBatch = buildErrorBatch(method.headerSchema, error, ctx.serverId, parsed.requestId);
|
|
117
|
-
|
|
129
|
+
const response = arrowResponse(serializeIpcStream(method.headerSchema, [errBatch]), 500);
|
|
130
|
+
(response as any).__dispatchError = error;
|
|
131
|
+
return response;
|
|
118
132
|
}
|
|
119
133
|
}
|
|
120
134
|
|
|
@@ -205,7 +219,7 @@ export async function httpDispatchStreamExchange(
|
|
|
205
219
|
// Exchange path — also handles exchange-registered methods acting as
|
|
206
220
|
// producers (__isProducer=true). Use producer mode on the OutputCollector
|
|
207
221
|
// when effectiveProducer so finish() is allowed.
|
|
208
|
-
const out = new OutputCollector(outputSchema, effectiveProducer, ctx.serverId, null);
|
|
222
|
+
const out = new OutputCollector(outputSchema, effectiveProducer, ctx.serverId, null, ctx.authContext);
|
|
209
223
|
|
|
210
224
|
// Cast compatible input types (e.g., decimal→double, int32→int64)
|
|
211
225
|
const conformedBatch = conformBatchToSchema(reqBatch, inputSchema);
|
|
@@ -224,7 +238,9 @@ export async function httpDispatchStreamExchange(
|
|
|
224
238
|
error.stack?.split("\n").slice(0, 5).join("\n"),
|
|
225
239
|
);
|
|
226
240
|
const errBatch = buildErrorBatch(outputSchema, error, ctx.serverId, null);
|
|
227
|
-
|
|
241
|
+
const response = arrowResponse(serializeIpcStream(outputSchema, [errBatch]), 500);
|
|
242
|
+
(response as any).__dispatchError = error;
|
|
243
|
+
return response;
|
|
228
244
|
}
|
|
229
245
|
|
|
230
246
|
// Collect emitted batches
|
|
@@ -281,9 +297,10 @@ async function produceStreamResponse(
|
|
|
281
297
|
const allBatches: RecordBatch[] = [];
|
|
282
298
|
const maxBytes = ctx.maxStreamResponseBytes;
|
|
283
299
|
let estimatedBytes = 0;
|
|
300
|
+
let producerError: Error | undefined;
|
|
284
301
|
|
|
285
302
|
while (true) {
|
|
286
|
-
const out = new OutputCollector(outputSchema, true, ctx.serverId, requestId);
|
|
303
|
+
const out = new OutputCollector(outputSchema, true, ctx.serverId, requestId, ctx.authContext);
|
|
287
304
|
|
|
288
305
|
try {
|
|
289
306
|
if (method.producerFn) {
|
|
@@ -300,6 +317,7 @@ async function produceStreamResponse(
|
|
|
300
317
|
if (process.env.VGI_DISPATCH_DEBUG)
|
|
301
318
|
console.error(`[produceStreamResponse] error:`, error.message, error.stack?.split("\n").slice(0, 3).join("\n"));
|
|
302
319
|
allBatches.push(buildErrorBatch(outputSchema, error, ctx.serverId, requestId));
|
|
320
|
+
producerError = error instanceof Error ? error : new Error(String(error));
|
|
303
321
|
break;
|
|
304
322
|
}
|
|
305
323
|
|
|
@@ -334,7 +352,11 @@ async function produceStreamResponse(
|
|
|
334
352
|
} else {
|
|
335
353
|
responseBody = dataBytes;
|
|
336
354
|
}
|
|
337
|
-
|
|
355
|
+
const response = arrowResponse(responseBody);
|
|
356
|
+
if (producerError) {
|
|
357
|
+
(response as any).__dispatchError = producerError;
|
|
358
|
+
}
|
|
359
|
+
return response;
|
|
338
360
|
}
|
|
339
361
|
|
|
340
362
|
function concatBytes(...arrays: Uint8Array[]): Uint8Array {
|
package/src/http/handler.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
5
|
import { Schema } from "@query-farm/apache-arrow";
|
|
6
|
+
import type { AuthContext } from "../auth.js";
|
|
6
7
|
import { DESCRIBE_METHOD_NAME } from "../constants.js";
|
|
7
8
|
import type { Protocol } from "../protocol.js";
|
|
8
|
-
import { MethodType } from "../types.js";
|
|
9
|
+
import { type CallStatistics, type DispatchInfo, MethodType } from "../types.js";
|
|
9
10
|
import { zstdCompress, zstdDecompress } from "../util/zstd.js";
|
|
10
11
|
import { buildErrorBatch } from "../wire/response.js";
|
|
12
|
+
import { buildWwwAuthenticateHeader, oauthResourceMetadataToJson, wellKnownPath } from "./auth.js";
|
|
11
13
|
import { ARROW_CONTENT_TYPE, arrowResponse, HttpRpcError, serializeIpcStream } from "./common.js";
|
|
12
14
|
import {
|
|
13
15
|
httpDispatchDescribe,
|
|
@@ -15,6 +17,7 @@ import {
|
|
|
15
17
|
httpDispatchStreamInit,
|
|
16
18
|
httpDispatchUnary,
|
|
17
19
|
} from "./dispatch.js";
|
|
20
|
+
import { buildDescribePage, buildLandingPage, buildNotFoundPage } from "./pages.js";
|
|
18
21
|
import { type HttpHandlerOptions, jsonStateSerializer } from "./types.js";
|
|
19
22
|
|
|
20
23
|
const EMPTY_SCHEMA = new Schema([]);
|
|
@@ -35,7 +38,7 @@ export function createHttpHandler(
|
|
|
35
38
|
protocol: Protocol,
|
|
36
39
|
options?: HttpHandlerOptions,
|
|
37
40
|
): (request: Request) => Response | Promise<Response> {
|
|
38
|
-
const prefix = (options?.prefix ?? "
|
|
41
|
+
const prefix = (options?.prefix ?? "").replace(/\/+$/, "");
|
|
39
42
|
const signingKey = options?.signingKey ?? randomBytes(32);
|
|
40
43
|
const tokenTtl = options?.tokenTtl ?? 3600;
|
|
41
44
|
const corsOrigins = options?.corsOrigins;
|
|
@@ -43,17 +46,39 @@ export function createHttpHandler(
|
|
|
43
46
|
const maxStreamResponseBytes = options?.maxStreamResponseBytes;
|
|
44
47
|
const serverId = options?.serverId ?? crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
|
45
48
|
|
|
49
|
+
const authenticate = options?.authenticate;
|
|
50
|
+
const oauthMetadata = options?.oauthResourceMetadata;
|
|
51
|
+
|
|
46
52
|
const methods = protocol.getMethods();
|
|
47
53
|
|
|
48
54
|
const compressionLevel = options?.compressionLevel;
|
|
49
55
|
const stateSerializer = options?.stateSerializer ?? jsonStateSerializer;
|
|
56
|
+
const dispatchHook = options?.dispatchHook;
|
|
57
|
+
|
|
58
|
+
// HTML page configuration
|
|
59
|
+
const enableLandingPage = options?.enableLandingPage ?? true;
|
|
60
|
+
const enableDescribePage = options?.enableDescribePage ?? true;
|
|
61
|
+
const enableNotFoundPage = options?.enableNotFoundPage ?? true;
|
|
62
|
+
const displayName = options?.protocolName ?? protocol.name;
|
|
63
|
+
const repoUrl = options?.repositoryUrl ?? null;
|
|
64
|
+
|
|
65
|
+
// Pre-render HTML pages for zero per-request overhead
|
|
66
|
+
const landingHtml = enableLandingPage
|
|
67
|
+
? buildLandingPage(displayName, serverId, enableDescribePage ? `${prefix}/describe` : null, repoUrl)
|
|
68
|
+
: null;
|
|
69
|
+
const describeHtml = enableDescribePage ? buildDescribePage(displayName, serverId, methods, repoUrl) : null;
|
|
70
|
+
const notFoundHtml = enableNotFoundPage ? buildNotFoundPage(prefix, displayName) : null;
|
|
71
|
+
|
|
72
|
+
const externalLocation = options?.externalLocation;
|
|
50
73
|
|
|
51
|
-
|
|
74
|
+
// ctx is built per-request to include authContext; base fields set here
|
|
75
|
+
const baseCtx = {
|
|
52
76
|
signingKey,
|
|
53
77
|
tokenTtl,
|
|
54
78
|
serverId,
|
|
55
79
|
maxStreamResponseBytes,
|
|
56
80
|
stateSerializer,
|
|
81
|
+
externalLocation,
|
|
57
82
|
};
|
|
58
83
|
|
|
59
84
|
function addCorsHeaders(headers: Headers): void {
|
|
@@ -88,6 +113,20 @@ export function createHttpHandler(
|
|
|
88
113
|
const url = new URL(request.url);
|
|
89
114
|
const path = url.pathname;
|
|
90
115
|
|
|
116
|
+
// Well-known endpoint: RFC 9728 OAuth Protected Resource Metadata
|
|
117
|
+
if (oauthMetadata && path === wellKnownPath(prefix)) {
|
|
118
|
+
if (request.method !== "GET") {
|
|
119
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
120
|
+
}
|
|
121
|
+
const body = JSON.stringify(oauthResourceMetadataToJson(oauthMetadata));
|
|
122
|
+
const headers = new Headers({
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
"Cache-Control": "public, max-age=60",
|
|
125
|
+
});
|
|
126
|
+
addCorsHeaders(headers);
|
|
127
|
+
return new Response(body, { status: 200, headers });
|
|
128
|
+
}
|
|
129
|
+
|
|
91
130
|
// CORS preflight
|
|
92
131
|
if (request.method === "OPTIONS") {
|
|
93
132
|
if (path === `${prefix}/__capabilities__`) {
|
|
@@ -108,6 +147,32 @@ export function createHttpHandler(
|
|
|
108
147
|
return new Response(null, { status: 405 });
|
|
109
148
|
}
|
|
110
149
|
|
|
150
|
+
// HTML pages for GET requests
|
|
151
|
+
if (request.method === "GET") {
|
|
152
|
+
// Landing page: GET {prefix}/ or GET {prefix}
|
|
153
|
+
if (landingHtml && (path === prefix || path === `${prefix}/`)) {
|
|
154
|
+
const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
|
|
155
|
+
addCorsHeaders(headers);
|
|
156
|
+
return new Response(landingHtml, { status: 200, headers });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Describe page: GET {prefix}/describe
|
|
160
|
+
if (describeHtml && path === `${prefix}/describe`) {
|
|
161
|
+
const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
|
|
162
|
+
addCorsHeaders(headers);
|
|
163
|
+
return new Response(describeHtml, { status: 200, headers });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 404 page for any other GET
|
|
167
|
+
if (notFoundHtml) {
|
|
168
|
+
const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
|
|
169
|
+
addCorsHeaders(headers);
|
|
170
|
+
return new Response(notFoundHtml, { status: 404, headers });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return new Response("Not Found", { status: 404 });
|
|
174
|
+
}
|
|
175
|
+
|
|
111
176
|
if (request.method !== "POST") {
|
|
112
177
|
return new Response("Method Not Allowed", { status: 405 });
|
|
113
178
|
}
|
|
@@ -135,6 +200,36 @@ export function createHttpHandler(
|
|
|
135
200
|
body = zstdDecompress(body);
|
|
136
201
|
}
|
|
137
202
|
|
|
203
|
+
// Build per-request dispatch context
|
|
204
|
+
const ctx = { ...baseCtx } as typeof baseCtx & { authContext?: AuthContext };
|
|
205
|
+
|
|
206
|
+
// Authentication
|
|
207
|
+
if (authenticate) {
|
|
208
|
+
try {
|
|
209
|
+
ctx.authContext = await authenticate(request);
|
|
210
|
+
} catch (error: any) {
|
|
211
|
+
const headers = new Headers({ "Content-Type": "text/plain" });
|
|
212
|
+
addCorsHeaders(headers);
|
|
213
|
+
if (oauthMetadata) {
|
|
214
|
+
const metadataUrl = new URL(request.url);
|
|
215
|
+
metadataUrl.pathname = wellKnownPath(prefix);
|
|
216
|
+
metadataUrl.search = "";
|
|
217
|
+
headers.set(
|
|
218
|
+
"WWW-Authenticate",
|
|
219
|
+
buildWwwAuthenticateHeader(
|
|
220
|
+
metadataUrl.toString(),
|
|
221
|
+
oauthMetadata.clientId,
|
|
222
|
+
oauthMetadata.clientSecret,
|
|
223
|
+
oauthMetadata.useIdTokenAsBearer,
|
|
224
|
+
oauthMetadata.deviceCodeClientId,
|
|
225
|
+
oauthMetadata.deviceCodeClientSecret,
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return new Response(error.message || "Unauthorized", { status: 401, headers });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
138
233
|
// Route: {prefix}/__describe__
|
|
139
234
|
if (path === `${prefix}/${DESCRIBE_METHOD_NAME}`) {
|
|
140
235
|
try {
|
|
@@ -174,6 +269,20 @@ export function createHttpHandler(
|
|
|
174
269
|
return compressIfAccepted(makeErrorResponse(err, 404), clientAcceptsZstd);
|
|
175
270
|
}
|
|
176
271
|
|
|
272
|
+
const methodType = method.type === MethodType.UNARY ? "unary" : "stream";
|
|
273
|
+
const info: DispatchInfo = { method: methodName, methodType, serverId, requestId: null };
|
|
274
|
+
const stats: CallStatistics = {
|
|
275
|
+
inputBatches: 0,
|
|
276
|
+
outputBatches: 0,
|
|
277
|
+
inputRows: 0,
|
|
278
|
+
outputRows: 0,
|
|
279
|
+
inputBytes: 0,
|
|
280
|
+
outputBytes: 0,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const hookToken = dispatchHook?.onDispatchStart(info);
|
|
284
|
+
let dispatchError: Error | undefined;
|
|
285
|
+
|
|
177
286
|
try {
|
|
178
287
|
let response: Response;
|
|
179
288
|
|
|
@@ -200,13 +309,21 @@ export function createHttpHandler(
|
|
|
200
309
|
response = await httpDispatchStreamExchange(method, body, ctx);
|
|
201
310
|
}
|
|
202
311
|
|
|
312
|
+
// Check if the dispatch function caught an error internally
|
|
313
|
+
const internalError = (response as any).__dispatchError;
|
|
314
|
+
if (internalError) {
|
|
315
|
+
dispatchError = internalError instanceof Error ? internalError : new Error(String(internalError));
|
|
316
|
+
}
|
|
203
317
|
addCorsHeaders(response.headers);
|
|
204
318
|
return compressIfAccepted(response, clientAcceptsZstd);
|
|
205
319
|
} catch (error: any) {
|
|
320
|
+
dispatchError = error instanceof Error ? error : new Error(String(error));
|
|
206
321
|
if (error instanceof HttpRpcError) {
|
|
207
322
|
return compressIfAccepted(makeErrorResponse(error, error.statusCode), clientAcceptsZstd);
|
|
208
323
|
}
|
|
209
324
|
return compressIfAccepted(makeErrorResponse(error, 500), clientAcceptsZstd);
|
|
325
|
+
} finally {
|
|
326
|
+
dispatchHook?.onDispatchEnd(hookToken, info, stats, dispatchError);
|
|
210
327
|
}
|
|
211
328
|
};
|
|
212
329
|
}
|
package/src/http/index.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
+
export type { AuthenticateFn, OAuthResourceMetadata } from "./auth.js";
|
|
5
|
+
export { oauthResourceMetadataToJson } from "./auth.js";
|
|
6
|
+
export type { BearerValidateFn } from "./bearer.js";
|
|
7
|
+
export { bearerAuthenticate, bearerAuthenticateStatic, chainAuthenticate } from "./bearer.js";
|
|
4
8
|
export { ARROW_CONTENT_TYPE } from "./common.js";
|
|
5
9
|
export { createHttpHandler } from "./handler.js";
|
|
10
|
+
export type { JwtAuthenticateOptions } from "./jwt.js";
|
|
11
|
+
export { jwtAuthenticate } from "./jwt.js";
|
|
12
|
+
export type { CertValidateFn, XfccElement, XfccValidateFn } from "./mtls.js";
|
|
13
|
+
export {
|
|
14
|
+
mtlsAuthenticate,
|
|
15
|
+
mtlsAuthenticateFingerprint,
|
|
16
|
+
mtlsAuthenticateSubject,
|
|
17
|
+
mtlsAuthenticateXfcc,
|
|
18
|
+
parseXfcc,
|
|
19
|
+
} from "./mtls.js";
|
|
6
20
|
export { type UnpackedToken, unpackStateToken } from "./token.js";
|
|
7
21
|
export type { HttpHandlerOptions, StateSerializer } from "./types.js";
|
|
8
22
|
export { jsonStateSerializer } from "./types.js";
|
package/src/http/jwt.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import * as oauth from "oauth4webapi";
|
|
5
|
+
import { AuthContext } from "../auth.js";
|
|
6
|
+
import type { AuthenticateFn } from "./auth.js";
|
|
7
|
+
|
|
8
|
+
export interface JwtAuthenticateOptions {
|
|
9
|
+
/** The expected `iss` claim (also used to discover AS metadata). */
|
|
10
|
+
issuer: string;
|
|
11
|
+
/** The expected `aud` claim. If an array, tries each audience in order. */
|
|
12
|
+
audience: string | string[];
|
|
13
|
+
/** Explicit JWKS URI. If omitted, discovered from issuer metadata. */
|
|
14
|
+
jwksUri?: string;
|
|
15
|
+
/** JWT claim to use as the principal. Default: "sub". */
|
|
16
|
+
principalClaim?: string;
|
|
17
|
+
/** AuthContext domain. Default: "jwt". */
|
|
18
|
+
domain?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create an AuthenticateFn that validates JWT Bearer tokens using oauth4webapi.
|
|
23
|
+
*
|
|
24
|
+
* On first call, discovers the Authorization Server metadata from the issuer
|
|
25
|
+
* to obtain the JWKS URI (unless `jwksUri` is provided directly).
|
|
26
|
+
*/
|
|
27
|
+
export function jwtAuthenticate(options: JwtAuthenticateOptions): AuthenticateFn {
|
|
28
|
+
const principalClaim = options.principalClaim ?? "sub";
|
|
29
|
+
const domain = options.domain ?? "jwt";
|
|
30
|
+
const audience = options.audience;
|
|
31
|
+
|
|
32
|
+
let asPromise: Promise<oauth.AuthorizationServer> | null = null;
|
|
33
|
+
|
|
34
|
+
async function getAuthorizationServer(): Promise<oauth.AuthorizationServer> {
|
|
35
|
+
if (options.jwksUri) {
|
|
36
|
+
return {
|
|
37
|
+
issuer: options.issuer as `https://${string}`,
|
|
38
|
+
jwks_uri: options.jwksUri,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const issuerUrl = new URL(options.issuer);
|
|
42
|
+
const response = await oauth.discoveryRequest(issuerUrl);
|
|
43
|
+
return oauth.processDiscoveryResponse(issuerUrl, response);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return async function authenticate(request: Request): Promise<AuthContext> {
|
|
47
|
+
if (!asPromise) {
|
|
48
|
+
asPromise = getAuthorizationServer();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let as: oauth.AuthorizationServer;
|
|
52
|
+
try {
|
|
53
|
+
as = await asPromise;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// Reset so next request retries discovery
|
|
56
|
+
asPromise = null;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// validateJwtAccessToken throws on failure, returns claims on success
|
|
61
|
+
// oauth4webapi only accepts a single string, so iterate if multiple audiences
|
|
62
|
+
const audiences = Array.isArray(audience) ? audience : [audience];
|
|
63
|
+
let claims: oauth.JWTAccessTokenClaims | undefined;
|
|
64
|
+
let lastError: unknown;
|
|
65
|
+
for (const aud of audiences) {
|
|
66
|
+
try {
|
|
67
|
+
claims = await oauth.validateJwtAccessToken(as, request, aud);
|
|
68
|
+
break;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
lastError = error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!claims) {
|
|
74
|
+
throw lastError;
|
|
75
|
+
}
|
|
76
|
+
const principal = (claims[principalClaim] as string | undefined) ?? null;
|
|
77
|
+
|
|
78
|
+
return new AuthContext(domain, true, principal, claims as unknown as Record<string, any>);
|
|
79
|
+
};
|
|
80
|
+
}
|