@query-farm/vgi-rpc 0.3.3 → 0.4.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.
Files changed (55) hide show
  1. package/dist/auth.d.ts +13 -0
  2. package/dist/auth.d.ts.map +1 -0
  3. package/dist/client/connect.d.ts.map +1 -1
  4. package/dist/client/index.d.ts +2 -0
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/client/introspect.d.ts +1 -0
  7. package/dist/client/introspect.d.ts.map +1 -1
  8. package/dist/client/oauth.d.ts +26 -0
  9. package/dist/client/oauth.d.ts.map +1 -0
  10. package/dist/client/stream.d.ts +2 -0
  11. package/dist/client/stream.d.ts.map +1 -1
  12. package/dist/client/types.d.ts +2 -0
  13. package/dist/client/types.d.ts.map +1 -1
  14. package/dist/dispatch/stream.d.ts.map +1 -1
  15. package/dist/http/auth.d.ts +21 -0
  16. package/dist/http/auth.d.ts.map +1 -0
  17. package/dist/http/common.d.ts.map +1 -1
  18. package/dist/http/dispatch.d.ts +2 -0
  19. package/dist/http/dispatch.d.ts.map +1 -1
  20. package/dist/http/handler.d.ts.map +1 -1
  21. package/dist/http/index.d.ts +4 -0
  22. package/dist/http/index.d.ts.map +1 -1
  23. package/dist/http/jwt.d.ts +21 -0
  24. package/dist/http/jwt.d.ts.map +1 -0
  25. package/dist/http/types.d.ts +5 -0
  26. package/dist/http/types.d.ts.map +1 -1
  27. package/dist/index.d.ts +3 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1479 -71
  30. package/dist/index.js.map +20 -15
  31. package/dist/types.d.ts +8 -2
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/util/conform.d.ts +5 -3
  34. package/dist/util/conform.d.ts.map +1 -1
  35. package/dist/wire/response.d.ts.map +1 -1
  36. package/package.json +3 -2
  37. package/src/auth.ts +31 -0
  38. package/src/client/connect.ts +15 -1
  39. package/src/client/index.ts +2 -0
  40. package/src/client/introspect.ts +14 -2
  41. package/src/client/oauth.ts +74 -0
  42. package/src/client/stream.ts +12 -0
  43. package/src/client/types.ts +2 -0
  44. package/src/dispatch/stream.ts +11 -5
  45. package/src/http/auth.ts +47 -0
  46. package/src/http/common.ts +1 -6
  47. package/src/http/dispatch.ts +6 -4
  48. package/src/http/handler.ts +41 -1
  49. package/src/http/index.ts +4 -0
  50. package/src/http/jwt.ts +66 -0
  51. package/src/http/types.ts +6 -0
  52. package/src/index.ts +7 -0
  53. package/src/types.ts +17 -3
  54. package/src/util/conform.ts +68 -5
  55. package/src/wire/response.ts +28 -14
@@ -0,0 +1,66 @@
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. */
12
+ audience: 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
+ const claims = await oauth.validateJwtAccessToken(as, request, audience);
62
+ const principal = (claims[principalClaim] as string | undefined) ?? null;
63
+
64
+ return new AuthContext(domain, true, principal, claims as unknown as Record<string, any>);
65
+ };
66
+ }
package/src/http/types.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ import type { AuthenticateFn, OAuthResourceMetadata } from "./auth.js";
5
+
4
6
  /** Configuration options for createHttpHandler(). */
5
7
  export interface HttpHandlerOptions {
6
8
  /** URL path prefix for all endpoints. Default: "/vgi" */
@@ -22,6 +24,10 @@ export interface HttpHandlerOptions {
22
24
  /** zstd compression level for responses (1-22). If set, responses are
23
25
  * compressed when the client sends Accept-Encoding: zstd. */
24
26
  compressionLevel?: number;
27
+ /** Optional authentication callback. Called for each request before dispatch. */
28
+ authenticate?: AuthenticateFn;
29
+ /** Optional RFC 9728 OAuth Protected Resource Metadata. Served at well-known endpoint. */
30
+ oauthResourceMetadata?: OAuthResourceMetadata;
25
31
  }
26
32
 
27
33
  /** Serializer for stream state objects stored in state tokens. */
package/src/index.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
+ export { AuthContext } from "./auth.js";
4
5
  export * from "./client/index.js";
5
6
  export {
6
7
  DESCRIBE_METHOD_NAME,
@@ -20,9 +21,14 @@ export {
20
21
  export { RpcError, VersionError } from "./errors.js";
21
22
  export {
22
23
  ARROW_CONTENT_TYPE,
24
+ type AuthenticateFn,
23
25
  createHttpHandler,
24
26
  type HttpHandlerOptions,
27
+ type JwtAuthenticateOptions,
25
28
  jsonStateSerializer,
29
+ jwtAuthenticate,
30
+ type OAuthResourceMetadata,
31
+ oauthResourceMetadataToJson,
26
32
  type StateSerializer,
27
33
  type UnpackedToken,
28
34
  unpackStateToken,
@@ -42,6 +48,7 @@ export {
42
48
  } from "./schema.js";
43
49
  export { VgiRpcServer } from "./server.js";
44
50
  export {
51
+ type CallContext,
45
52
  type ExchangeFn,
46
53
  type ExchangeInit,
47
54
  type HeaderInit,
package/src/types.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { RecordBatch, recordBatchFromArrays, type Schema } from "@query-farm/apache-arrow";
5
+ import { AuthContext } from "./auth.js";
5
6
  import { buildLogBatch, coerceInt64 } from "./wire/response.js";
6
7
 
7
8
  export enum MethodType {
@@ -14,6 +15,11 @@ export interface LogContext {
14
15
  clientLog(level: string, message: string, extra?: Record<string, string>): void;
15
16
  }
16
17
 
18
+ /** Extended context with authentication info, available to handlers. */
19
+ export interface CallContext extends LogContext {
20
+ readonly auth: AuthContext;
21
+ }
22
+
17
23
  /** Handler for unary (request-response) RPC methods. */
18
24
  export type UnaryHandler = (
19
25
  params: Record<string, any>,
@@ -61,7 +67,7 @@ export interface EmittedBatch {
61
67
  * Accumulates output batches during a produce/exchange call.
62
68
  * Enforces that exactly one data batch is emitted per call (plus any number of log batches).
63
69
  */
64
- export class OutputCollector implements LogContext {
70
+ export class OutputCollector implements CallContext {
65
71
  private _batches: EmittedBatch[] = [];
66
72
  private _dataBatchIdx: number | null = null;
67
73
  private _finished = false;
@@ -69,12 +75,20 @@ export class OutputCollector implements LogContext {
69
75
  private _outputSchema: Schema;
70
76
  private _serverId: string;
71
77
  private _requestId: string | null;
72
-
73
- constructor(outputSchema: Schema, producerMode = true, serverId = "", requestId: string | null = null) {
78
+ readonly auth: AuthContext;
79
+
80
+ constructor(
81
+ outputSchema: Schema,
82
+ producerMode = true,
83
+ serverId = "",
84
+ requestId: string | null = null,
85
+ authContext?: AuthContext,
86
+ ) {
74
87
  this._outputSchema = outputSchema;
75
88
  this._producerMode = producerMode;
76
89
  this._serverId = serverId;
77
90
  this._requestId = requestId;
91
+ this.auth = authContext ?? AuthContext.anonymous();
78
92
  }
79
93
 
80
94
  get outputSchema(): Schema {
@@ -1,7 +1,31 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { RecordBatch, Struct, makeData, type Schema } from "@query-farm/apache-arrow";
4
+ import {
5
+ type DataType,
6
+ makeData,
7
+ RecordBatch,
8
+ type Schema,
9
+ Struct,
10
+ Type,
11
+ vectorFromArray,
12
+ } from "@query-farm/apache-arrow";
13
+
14
+ /** Return true when the source type's values can be losslessly read and
15
+ * re-encoded into the target type (e.g., int32 → float64). */
16
+ function needsValueCast(src: DataType, dst: DataType): boolean {
17
+ if (src.typeId === dst.typeId) return false;
18
+ // Same broad family (e.g. Float → Float64) — clone is sufficient.
19
+ if (src.constructor === dst.constructor) return false;
20
+ return true;
21
+ }
22
+
23
+ /** Check if a type is a numeric type we can cast between.
24
+ * Uses typeId instead of instanceof because IPC-deserialized types
25
+ * may be generic (e.g., Int_ instead of Int64). */
26
+ function isNumeric(t: DataType): boolean {
27
+ return t.typeId === Type.Int || t.typeId === Type.Float;
28
+ }
5
29
 
6
30
  /**
7
31
  * Rebuild a batch's data to match the given schema's field types.
@@ -12,13 +36,52 @@ import { RecordBatch, Struct, makeData, type Schema } from "@query-farm/apache-a
12
36
  * match the writer's schema. Cloning each child Data with the schema's field
13
37
  * type fixes the type metadata while preserving the underlying buffers.
14
38
  *
15
- * This is also used to cast compatible input types (e.g., decimaldouble,
16
- * int32int64) when the input batch schema doesn't exactly match the method's
17
- * declared input schema.
39
+ * This is also used to cast compatible input types (e.g., int32float64,
40
+ * float32float64) when the input batch schema doesn't exactly match the
41
+ * method's declared input schema. When the underlying buffer layout differs
42
+ * (e.g., 4-byte int32 vs 8-byte float64), we read the values and build a
43
+ * new vector with the target type.
18
44
  */
19
45
  export function conformBatchToSchema(batch: RecordBatch, schema: Schema): RecordBatch {
20
46
  if (batch.numRows === 0) return batch;
21
- const children = schema.fields.map((f, i) => batch.data.children[i].clone(f.type));
47
+
48
+ // Validate field count and names match before attempting any cast.
49
+ if (batch.schema.fields.length !== schema.fields.length) {
50
+ throw new TypeError(`Field count mismatch: expected ${schema.fields.length}, got ${batch.schema.fields.length}`);
51
+ }
52
+ for (let i = 0; i < schema.fields.length; i++) {
53
+ if (batch.schema.fields[i].name !== schema.fields[i].name) {
54
+ throw new TypeError(
55
+ `Field name mismatch at index ${i}: expected '${schema.fields[i].name}', got '${batch.schema.fields[i].name}'`,
56
+ );
57
+ }
58
+ }
59
+
60
+ const children = schema.fields.map((f, i) => {
61
+ const srcChild = batch.data.children[i];
62
+ const srcType = srcChild.type;
63
+ const dstType = f.type;
64
+
65
+ if (!needsValueCast(srcType, dstType)) {
66
+ return srcChild.clone(dstType);
67
+ }
68
+
69
+ // Numeric → numeric: read values and rebuild with target type.
70
+ if (isNumeric(srcType) && isNumeric(dstType)) {
71
+ // Read source values via the batch's column vector.
72
+ const col = batch.getChildAt(i)!;
73
+ const values: number[] = [];
74
+ for (let r = 0; r < batch.numRows; r++) {
75
+ const v = col.get(r);
76
+ values.push(typeof v === "bigint" ? Number(v) : (v as number));
77
+ }
78
+ return vectorFromArray(values, dstType).data[0];
79
+ }
80
+
81
+ // Fallback: clone type metadata (works for same-layout types).
82
+ return srcChild.clone(dstType);
83
+ });
84
+
22
85
  const structType = new Struct(schema.fields);
23
86
  const data = makeData({
24
87
  type: structType,
@@ -133,25 +133,39 @@ export function buildLogBatch(
133
133
  return buildEmptyBatch(schema, metadata);
134
134
  }
135
135
 
136
+ /**
137
+ * Recursively create empty (0-row) Data for any Arrow type,
138
+ * including complex types (Struct, List, FixedSizeList, Map).
139
+ */
140
+ function makeEmptyData(type: DataType): Data {
141
+ if (DataType.isStruct(type)) {
142
+ const children = type.children.map((f: Field) => makeEmptyData(f.type));
143
+ return makeData({ type, length: 0, children, nullCount: 0 });
144
+ }
145
+ if (DataType.isList(type)) {
146
+ const childData = makeEmptyData(type.children[0].type);
147
+ return makeData({ type, length: 0, children: [childData], nullCount: 0, valueOffsets: new Int32Array([0]) } as any);
148
+ }
149
+ if (DataType.isFixedSizeList(type)) {
150
+ const childData = makeEmptyData(type.children[0].type);
151
+ return makeData({ type, length: 0, child: childData, nullCount: 0 } as any);
152
+ }
153
+ if (DataType.isMap(type)) {
154
+ const entryType = type.children[0]?.type;
155
+ const entryData = entryType
156
+ ? makeEmptyData(entryType)
157
+ : makeData({ type: new Struct([]), length: 0, children: [], nullCount: 0 });
158
+ return makeData({ type, length: 0, children: [entryData], nullCount: 0, valueOffsets: new Int32Array([0]) } as any);
159
+ }
160
+ return makeData({ type, length: 0, nullCount: 0 });
161
+ }
162
+
136
163
  /**
137
164
  * Build a 0-row batch from a schema with metadata.
138
165
  * Used for error/log batches.
139
166
  */
140
167
  export function buildEmptyBatch(schema: Schema, metadata?: Map<string, string>): RecordBatch {
141
- const children = schema.fields.map((f: Field) => {
142
- return makeData({ type: f.type, length: 0, nullCount: 0 });
143
- });
144
-
145
- if (schema.fields.length === 0) {
146
- const structType = new Struct(schema.fields);
147
- const data = makeData({
148
- type: structType,
149
- length: 0,
150
- children: [],
151
- nullCount: 0,
152
- });
153
- return new RecordBatch(schema, data, metadata);
154
- }
168
+ const children = schema.fields.map((f: Field) => makeEmptyData(f.type));
155
169
 
156
170
  const structType = new Struct(schema.fields);
157
171
  const data = makeData({