@query-farm/vgi 0.38.0 → 0.40.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 +31 -0
- package/dist/client/client.d.ts +31 -2
- package/dist/client/types.d.ts +56 -1
- package/dist/client-entry.js +99 -8
- package/dist/client-entry.js.map +4 -4
- package/dist/functions/copy-to.d.ts +1 -1
- package/dist/index.core.d.ts +3 -3
- package/dist/index.js +128 -10
- package/dist/index.js.map +7 -7
- package/dist/rpc-server.d.ts +23 -1
- package/dist/serve-entry.d.ts +13 -1
- package/dist/serve-entry.js +31 -5
- package/dist/serve-entry.js.map +5 -5
- package/dist/worker-cf-entry.d.ts +1 -1
- package/dist/worker-cf-entry.js +13 -3
- package/dist/worker-cf-entry.js.map +6 -6
- package/package.json +3 -3
- package/src/client/client.ts +148 -0
- package/src/client/types.ts +58 -1
- package/src/codec/asd.ts +12 -8
- package/src/functions/copy-to.ts +1 -1
- package/src/index.core.ts +4 -0
- package/src/rpc-server.ts +55 -1
- package/src/serve-entry.ts +19 -1
- package/src/worker-cf-entry.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@query-farm/vgi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "TypeScript framework for extending DuckDB with user-defined functions via the Vector Gateway Interface (VGI) over Arrow IPC.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Query Farm LLC (https://query.farm)",
|
|
@@ -91,12 +91,12 @@
|
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|
|
93
93
|
"@query-farm/apache-arrow": "^21.1.1",
|
|
94
|
-
"@query-farm/vgi-rpc": "^0.
|
|
94
|
+
"@query-farm/vgi-rpc": "^0.27.0"
|
|
95
95
|
},
|
|
96
96
|
"devDependencies": {
|
|
97
97
|
"@duckdb/node-api": "^1.5.5-r.4",
|
|
98
98
|
"@query-farm/apache-arrow": "^21.1.1",
|
|
99
|
-
"@query-farm/vgi-rpc": "^0.
|
|
99
|
+
"@query-farm/vgi-rpc": "^0.27.0",
|
|
100
100
|
"@types/bun": "latest",
|
|
101
101
|
"typescript": "^7.0.2"
|
|
102
102
|
},
|
package/src/client/client.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type CatalogAttachResult,
|
|
43
43
|
type SchemaContentsInfo,
|
|
44
44
|
decodeIndexInfo,
|
|
45
|
+
type IndexInfo,
|
|
45
46
|
type AttachOpaqueData,
|
|
46
47
|
type TransactionOpaqueData,
|
|
47
48
|
decodeCatalogInfo,
|
|
@@ -72,6 +73,8 @@ import type {
|
|
|
72
73
|
OrderByPushdown,
|
|
73
74
|
TablesamplePushdown,
|
|
74
75
|
AttachOptionValue,
|
|
76
|
+
CatalogSnapshot,
|
|
77
|
+
LoadCatalogOptions,
|
|
75
78
|
} from "./types.js";
|
|
76
79
|
|
|
77
80
|
/**
|
|
@@ -696,6 +699,9 @@ export class VgiClient {
|
|
|
696
699
|
* current the worker answers `not_modified` with no schemas. `etag` null
|
|
697
700
|
* means the worker does not revalidate. Each schema's wire `path` is checked
|
|
698
701
|
* against its decoded `SchemaInfo.path`.
|
|
702
|
+
*
|
|
703
|
+
* This is the raw RPC. To enumerate a catalog, use `loadCatalog`, which
|
|
704
|
+
* checks the capability, revalidates and falls back to the per-schema RPCs.
|
|
699
705
|
*/
|
|
700
706
|
async catalogContents(
|
|
701
707
|
attachOpaqueData: AttachOpaqueData,
|
|
@@ -734,6 +740,132 @@ export class VgiClient {
|
|
|
734
740
|
};
|
|
735
741
|
}
|
|
736
742
|
|
|
743
|
+
/**
|
|
744
|
+
* Enumerate the whole catalog: every schema with all of its tables, views,
|
|
745
|
+
* scalar/aggregate/table functions, scalar/table macros and indexes.
|
|
746
|
+
*
|
|
747
|
+
* Honors the attach result's `supports_catalog_contents` the way the DuckDB
|
|
748
|
+
* extension does:
|
|
749
|
+
*
|
|
750
|
+
* - advertised (and `useCatalogContents` not false, no transaction): one
|
|
751
|
+
* `catalog_contents` call. With `previous.etag` it is conditional
|
|
752
|
+
* (`if_none_match`); `not_modified` returns `previous`'s contents.
|
|
753
|
+
* - not advertised: `catalog_schemas` + per-schema
|
|
754
|
+
* `catalog_schema_contents_*` calls -- `catalog_contents` is never sent.
|
|
755
|
+
* Kinds whose `estimated_object_count` is exactly 0 are skipped.
|
|
756
|
+
* - advertised but the call fails (or answers malformed, e.g. a
|
|
757
|
+
* `SchemaContents.path` that disagrees with its `SchemaInfo.path`): falls
|
|
758
|
+
* back to the per-schema calls and reports the failure in
|
|
759
|
+
* `fallback_error`.
|
|
760
|
+
*
|
|
761
|
+
* A full answer older than `previous` (a lagging replica) is retried once,
|
|
762
|
+
* then the per-schema path is used.
|
|
763
|
+
*/
|
|
764
|
+
async loadCatalog(
|
|
765
|
+
attach: Pick<CatalogAttachResult, "attach_opaque_data" | "supports_catalog_contents">,
|
|
766
|
+
opts?: LoadCatalogOptions,
|
|
767
|
+
): Promise<CatalogSnapshot> {
|
|
768
|
+
const attachOpaqueData = attach.attach_opaque_data;
|
|
769
|
+
const previous = opts?.previous ?? null;
|
|
770
|
+
const useContents =
|
|
771
|
+
attach.supports_catalog_contents === true &&
|
|
772
|
+
opts?.useCatalogContents !== false &&
|
|
773
|
+
opts?.transactionOpaqueData == null;
|
|
774
|
+
if (!useContents) {
|
|
775
|
+
return this._loadCatalogPerSchema(attachOpaqueData, opts?.transactionOpaqueData, null);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
let fallbackError: string | null = null;
|
|
779
|
+
const ifNoneMatch = previous?.etag ?? null;
|
|
780
|
+
const knownVersion = previous?.catalog_version ?? 0;
|
|
781
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
782
|
+
let response: Awaited<ReturnType<VgiClient["catalogContents"]>>;
|
|
783
|
+
try {
|
|
784
|
+
response = await this.catalogContents(attachOpaqueData, ifNoneMatch);
|
|
785
|
+
} catch (err) {
|
|
786
|
+
fallbackError = err instanceof Error ? err.message : String(err);
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
if (response.not_modified) {
|
|
790
|
+
if (previous == null || ifNoneMatch == null) {
|
|
791
|
+
fallbackError = "catalog_contents answered not_modified to an unconditional request";
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
...previous,
|
|
796
|
+
// 0 = unknown: keep the version already known.
|
|
797
|
+
catalog_version: response.catalog_version !== 0 ? response.catalog_version : previous.catalog_version,
|
|
798
|
+
etag: response.etag ?? ifNoneMatch,
|
|
799
|
+
not_modified: true,
|
|
800
|
+
fallback_error: null,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
// Version adoption: a snapshot at least as new as the known version is
|
|
804
|
+
// current; 0 means unknown and is accepted. An older one is retried once.
|
|
805
|
+
if (response.catalog_version !== 0 && knownVersion !== 0 && response.catalog_version < knownVersion) {
|
|
806
|
+
fallbackError = `catalog_contents snapshot version ${response.catalog_version} is older than the known version ${knownVersion}`;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
catalog_version: response.catalog_version,
|
|
811
|
+
etag: response.etag,
|
|
812
|
+
source: "catalog_contents",
|
|
813
|
+
not_modified: false,
|
|
814
|
+
fallback_error: null,
|
|
815
|
+
schemas: response.schemas,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
return this._loadCatalogPerSchema(attachOpaqueData, undefined, fallbackError);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** `loadCatalog` without `catalog_contents`: catalog_schemas + per-schema calls. */
|
|
822
|
+
private async _loadCatalogPerSchema(
|
|
823
|
+
attachOpaqueData: AttachOpaqueData,
|
|
824
|
+
transactionOpaqueData: TransactionOpaqueData | undefined,
|
|
825
|
+
fallbackError: string | null,
|
|
826
|
+
): Promise<CatalogSnapshot> {
|
|
827
|
+
const schemas = await this.schemas(attachOpaqueData, transactionOpaqueData);
|
|
828
|
+
const out: SchemaContentsInfo[] = [];
|
|
829
|
+
// Sequential on purpose: a stdio RpcClient carries one call at a time.
|
|
830
|
+
for (const schema of schemas) {
|
|
831
|
+
const counts = schema.estimated_object_count ?? {};
|
|
832
|
+
const path = schema.path;
|
|
833
|
+
// Only an explicit 0 is a guarantee; a missing count is unknown. Decoded
|
|
834
|
+
// int64 counts are numbers, or bigints beyond the safe range, so compare
|
|
835
|
+
// numerically.
|
|
836
|
+
const has = (kind: string) => {
|
|
837
|
+
const n = (counts as Record<string, unknown>)[kind];
|
|
838
|
+
return n == null || Number(n) !== 0;
|
|
839
|
+
};
|
|
840
|
+
const t = transactionOpaqueData;
|
|
841
|
+
out.push({
|
|
842
|
+
schema,
|
|
843
|
+
tables: has("table") ? await this.schemaContentsTables(attachOpaqueData, path, t) : [],
|
|
844
|
+
views: has("view") ? await this.schemaContentsViews(attachOpaqueData, path, t) : [],
|
|
845
|
+
scalar_functions: has("scalar_function")
|
|
846
|
+
? await this.schemaContentsFunctions(attachOpaqueData, path, "SCALAR_FUNCTION", t)
|
|
847
|
+
: [],
|
|
848
|
+
aggregate_functions: has("aggregate_function")
|
|
849
|
+
? await this.schemaContentsFunctions(attachOpaqueData, path, "AGGREGATE_FUNCTION", t)
|
|
850
|
+
: [],
|
|
851
|
+
table_functions: has("table_function")
|
|
852
|
+
? await this.schemaContentsFunctions(attachOpaqueData, path, "TABLE_FUNCTION", t)
|
|
853
|
+
: [],
|
|
854
|
+
scalar_macros: has("macro") ? await this.schemaContentsMacros(attachOpaqueData, path, "SCALAR_MACRO", t) : [],
|
|
855
|
+
table_macros: has("macro") ? await this.schemaContentsMacros(attachOpaqueData, path, "TABLE_MACRO", t) : [],
|
|
856
|
+
indexes: has("index") ? await this.schemaContentsIndexes(attachOpaqueData, path, t) : [],
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
catalog_version: null,
|
|
861
|
+
etag: null,
|
|
862
|
+
source: "per_schema",
|
|
863
|
+
not_modified: false,
|
|
864
|
+
fallback_error: fallbackError,
|
|
865
|
+
schemas: out,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
737
869
|
/** Detach a previously-attached catalog. */
|
|
738
870
|
async catalogDetach(attachOpaqueData: AttachOpaqueData): Promise<void> {
|
|
739
871
|
await this.rpc.call("catalog_detach", { attach_opaque_data: attachOpaqueData });
|
|
@@ -955,6 +1087,22 @@ export class VgiClient {
|
|
|
955
1087
|
return deserializeInfoList(inner.items, decodeFunctionInfo);
|
|
956
1088
|
}
|
|
957
1089
|
|
|
1090
|
+
/** List indexes in a schema. */
|
|
1091
|
+
async schemaContentsIndexes(
|
|
1092
|
+
attachOpaqueData: AttachOpaqueData,
|
|
1093
|
+
path: string[] | string,
|
|
1094
|
+
transactionOpaqueData?: TransactionOpaqueData,
|
|
1095
|
+
): Promise<IndexInfo[]> {
|
|
1096
|
+
const result = await this.rpc.call("catalog_schema_contents_indexes", {
|
|
1097
|
+
attach_opaque_data: attachOpaqueData,
|
|
1098
|
+
path: normalizeSchemaPath(path),
|
|
1099
|
+
transaction_opaque_data: transactionOpaqueData ?? null,
|
|
1100
|
+
});
|
|
1101
|
+
if (!result) return [];
|
|
1102
|
+
const inner = unwrapResult(result);
|
|
1103
|
+
return deserializeInfoList(inner.items, decodeIndexInfo);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
958
1106
|
/** Get a table by name, or null if not found. */
|
|
959
1107
|
async tableGet(
|
|
960
1108
|
attachOpaqueData: AttachOpaqueData,
|
package/src/client/types.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { VgiBatch } from "../arrow/index.js";
|
|
|
5
5
|
import type { Arguments } from "../arguments/arguments.js";
|
|
6
6
|
import type { AttachOptionValue } from "../catalog/attach-options.js";
|
|
7
7
|
import type { BindResponse } from "../protocol/types.js";
|
|
8
|
+
import type { SchemaContentsInfo, TransactionOpaqueData } from "../catalog/interface.js";
|
|
8
9
|
import { OrderByDirection, OrderByNullOrder } from "../protocol/types.js";
|
|
9
10
|
|
|
10
11
|
export type { AttachOptionValue };
|
|
@@ -56,7 +57,7 @@ export interface CatalogAttachOptions {
|
|
|
56
57
|
export type OnCreateConflict = "error" | "ignore" | "replace";
|
|
57
58
|
|
|
58
59
|
/** DuckDB catalog function type filter (sent as uppercase wire values). */
|
|
59
|
-
export type CatalogFunctionType = "SCALAR_FUNCTION" | "TABLE_FUNCTION";
|
|
60
|
+
export type CatalogFunctionType = "SCALAR_FUNCTION" | "AGGREGATE_FUNCTION" | "TABLE_FUNCTION";
|
|
60
61
|
|
|
61
62
|
/** Macro type filter for schema contents listing (uppercase wire values). */
|
|
62
63
|
export type CatalogMacroType = "SCALAR_MACRO" | "TABLE_MACRO";
|
|
@@ -151,3 +152,59 @@ export interface TableInOutFunctionOptions {
|
|
|
151
152
|
*/
|
|
152
153
|
hasFinalize?: boolean;
|
|
153
154
|
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A whole-catalog snapshot returned by `VgiClient.loadCatalog`: every schema
|
|
158
|
+
* with all of its tables, views, functions, macros and indexes.
|
|
159
|
+
*
|
|
160
|
+
* Hold on to it and pass it back as `LoadCatalogOptions.previous` to
|
|
161
|
+
* revalidate: when it carries an `etag` the client sends that as
|
|
162
|
+
* `if_none_match`, and a `not_modified` answer returns this snapshot's
|
|
163
|
+
* contents unchanged.
|
|
164
|
+
*/
|
|
165
|
+
export interface CatalogSnapshot {
|
|
166
|
+
/**
|
|
167
|
+
* Version the snapshot was taken at, as reported by `catalog_contents`.
|
|
168
|
+
* `null` when it was assembled from the per-schema RPCs (which carry no
|
|
169
|
+
* version); 0 means the worker does not track versions.
|
|
170
|
+
*/
|
|
171
|
+
catalog_version: number | null;
|
|
172
|
+
/** Validator to revalidate with; null = the worker does not revalidate. */
|
|
173
|
+
etag: string | null;
|
|
174
|
+
/**
|
|
175
|
+
* How the contents were obtained: one `catalog_contents` call, or
|
|
176
|
+
* `catalog_schemas` + per-schema `catalog_schema_contents_*` calls.
|
|
177
|
+
*/
|
|
178
|
+
source: "catalog_contents" | "per_schema";
|
|
179
|
+
/** True when this load was a `not_modified` revalidation of `previous`. */
|
|
180
|
+
not_modified: boolean;
|
|
181
|
+
/**
|
|
182
|
+
* Set when the catalog advertised `catalog_contents` but the call failed
|
|
183
|
+
* (or returned a malformed answer) and the client fell back to the
|
|
184
|
+
* per-schema RPCs: the failure's message.
|
|
185
|
+
*/
|
|
186
|
+
fallback_error: string | null;
|
|
187
|
+
/** One entry per schema, parents before children. */
|
|
188
|
+
schemas: SchemaContentsInfo[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Options for `VgiClient.loadCatalog`. */
|
|
192
|
+
export interface LoadCatalogOptions {
|
|
193
|
+
/**
|
|
194
|
+
* A snapshot from an earlier load of the same attach. Its `etag` (if any)
|
|
195
|
+
* is sent as `if_none_match`; a `not_modified` answer keeps its contents.
|
|
196
|
+
*/
|
|
197
|
+
previous?: CatalogSnapshot | null;
|
|
198
|
+
/**
|
|
199
|
+
* Set false to never call `catalog_contents` (the client-side equivalent of
|
|
200
|
+
* DuckDB's `SET vgi_catalog_contents = false`). Default true: it is used
|
|
201
|
+
* whenever the attach result advertised `supports_catalog_contents`.
|
|
202
|
+
*/
|
|
203
|
+
useCatalogContents?: boolean;
|
|
204
|
+
/**
|
|
205
|
+
* Enumerate inside this transaction. `catalog_contents` returns only the
|
|
206
|
+
* committed catalog (it takes no transaction), so a transaction-scoped load
|
|
207
|
+
* always uses the per-schema RPCs.
|
|
208
|
+
*/
|
|
209
|
+
transactionOpaqueData?: TransactionOpaqueData;
|
|
210
|
+
}
|
package/src/codec/asd.ts
CHANGED
|
@@ -175,23 +175,27 @@ function normalizeValue(raw: any, type: VgiDataType): any {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
if (isMap(type)) {
|
|
178
|
-
|
|
178
|
+
// Values are decoded with the map's declared value type, like list items
|
|
179
|
+
// and struct children: a map<utf8, int64> yields numbers (bigint beyond
|
|
180
|
+
// the safe range), not strings, and a null value stays null.
|
|
181
|
+
const entries = (type as any).children[0].type as VgiDataType;
|
|
182
|
+
const valueType = ((entries as any).children as VgiField[])[1].type;
|
|
183
|
+
const out: Record<string, any> = {};
|
|
184
|
+
const put = (k: unknown, v: unknown): void => {
|
|
185
|
+
out[String(k)] = normalizeValue(v, valueType);
|
|
186
|
+
};
|
|
179
187
|
// arrow-js MapRow is iterable of [k,v]; flechette returns [[k,v],...] arrays.
|
|
180
188
|
if (raw[Symbol.iterator]) {
|
|
181
189
|
for (const entry of raw) {
|
|
182
190
|
if (Array.isArray(entry)) {
|
|
183
|
-
|
|
191
|
+
put(entry[0], entry[1]);
|
|
184
192
|
} else if (entry && typeof entry === "object") {
|
|
185
|
-
|
|
186
|
-
const v = entry.value ?? entry[1];
|
|
187
|
-
out[String(k)] = v == null ? "" : String(v);
|
|
193
|
+
put(entry.key ?? entry[0], entry.value ?? entry[1]);
|
|
188
194
|
}
|
|
189
195
|
}
|
|
190
196
|
} else if (typeof raw === "object") {
|
|
191
197
|
// Plain-object map (flechette without useMap): Object.entries fallback
|
|
192
|
-
for (const [k, v] of Object.entries(raw))
|
|
193
|
-
out[String(k)] = v == null ? "" : String(v);
|
|
194
|
-
}
|
|
198
|
+
for (const [k, v] of Object.entries(raw)) put(k, v);
|
|
195
199
|
}
|
|
196
200
|
return out;
|
|
197
201
|
}
|
package/src/functions/copy-to.ts
CHANGED
|
@@ -75,7 +75,7 @@ export interface CopyToOption {
|
|
|
75
75
|
* Optional allowed value set, validated worker-side at bind. Mirrors
|
|
76
76
|
* vgi-python's `Arg(..., choices=[...])`.
|
|
77
77
|
*/
|
|
78
|
-
choices?: unknown[];
|
|
78
|
+
choices?: readonly unknown[];
|
|
79
79
|
/**
|
|
80
80
|
* Optional inclusive lower bound for numeric options, validated worker-side at
|
|
81
81
|
* bind. Mirrors vgi-python's `Arg(..., ge=...)`.
|
package/src/index.core.ts
CHANGED
|
@@ -303,6 +303,8 @@ export type {
|
|
|
303
303
|
CatalogFunctionType as ClientCatalogFunctionType,
|
|
304
304
|
CatalogAttachOptions,
|
|
305
305
|
AttachOptionValue,
|
|
306
|
+
CatalogSnapshot,
|
|
307
|
+
LoadCatalogOptions,
|
|
306
308
|
} from "./client/types.js";
|
|
307
309
|
|
|
308
310
|
// Re-export from vgi-rpc for convenience
|
|
@@ -316,6 +318,7 @@ export type { RpcClient, StreamSession, LogMessage } from "@query-farm/vgi-rpc";
|
|
|
316
318
|
// GrantRefusedError to decline a grant.
|
|
317
319
|
export {
|
|
318
320
|
buildRpcServer,
|
|
321
|
+
grantKeysFromArgv,
|
|
319
322
|
INTROSPECT_PRINCIPALS_ENV,
|
|
320
323
|
type BuildRpcServerOptions,
|
|
321
324
|
type HostingOptions,
|
|
@@ -323,6 +326,7 @@ export {
|
|
|
323
326
|
} from "./rpc-server.js";
|
|
324
327
|
export {
|
|
325
328
|
AuthUnavailableError,
|
|
329
|
+
GrantKeys,
|
|
326
330
|
GrantRefusedError,
|
|
327
331
|
IdentityUnavailableError,
|
|
328
332
|
StatusError,
|
package/src/rpc-server.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// exactly as it would against a single-protocol server.
|
|
23
23
|
|
|
24
24
|
import {
|
|
25
|
+
GrantKeys,
|
|
25
26
|
type GrantMinter,
|
|
26
27
|
IdentityImpl,
|
|
27
28
|
type Protocol,
|
|
@@ -117,6 +118,20 @@ export interface HostingOptions {
|
|
|
117
118
|
/** Maximum age of the caller's authentication for `issue_grant`, in
|
|
118
119
|
* seconds. vgi-rpc's default when omitted. */
|
|
119
120
|
maxAuthAge?: number;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sealed-grant keys (vgi-rpc `GrantKeys`), turning on the framework's own
|
|
124
|
+
* grants on HTTP: `issue_grant` mints `vgig1.` tokens (unless
|
|
125
|
+
* {@link mintGrant} is supplied) and the worker's HTTP authentication accepts
|
|
126
|
+
* them back as bearer credentials, as the grant's owner. When omitted,
|
|
127
|
+
* `VGI_RPC_GRANT_KEYS` (comma-separated base64, exactly 32 bytes each, first
|
|
128
|
+
* mints, all verify), `VGI_RPC_GRANT_AUDIENCE` and
|
|
129
|
+
* `VGI_RPC_GRANT_MAX_TTL_SECONDS` are read; `serveVgiWorker` also accepts
|
|
130
|
+
* `--grant-key KEY` (repeatable). Unset everywhere, nothing changes. A
|
|
131
|
+
* malformed key refuses to start. `null` turns grants off regardless of the
|
|
132
|
+
* environment.
|
|
133
|
+
*/
|
|
134
|
+
grantKeys?: GrantKeys | null;
|
|
120
135
|
}
|
|
121
136
|
|
|
122
137
|
/** Server-level options passed through to `VgiRpcServer`. */
|
|
@@ -156,6 +171,9 @@ export function buildRpcServer(
|
|
|
156
171
|
serverId: options.serverId,
|
|
157
172
|
protocols,
|
|
158
173
|
identity,
|
|
174
|
+
// Resolved here, per transport, rather than by vgi-rpc's own environment
|
|
175
|
+
// default: identity (and so grants) is hosted on HTTP only.
|
|
176
|
+
grantKeys: null,
|
|
159
177
|
});
|
|
160
178
|
}
|
|
161
179
|
|
|
@@ -224,10 +242,15 @@ function buildIdentity(
|
|
|
224
242
|
env: Record<string, string | undefined> | undefined,
|
|
225
243
|
): IdentityImpl | undefined {
|
|
226
244
|
const { resolveToken, mintGrant } = hosting;
|
|
227
|
-
|
|
245
|
+
// Explicit option, else the environment. Read at server build, so a
|
|
246
|
+
// malformed key stops the worker before it serves anything.
|
|
247
|
+
const grantKeys = hosting.grantKeys === undefined ? GrantKeys.fromEnv(env ?? processEnv()) : hosting.grantKeys;
|
|
248
|
+
if (!resolveToken && !mintGrant && !grantKeys) return undefined;
|
|
228
249
|
return new IdentityImpl({
|
|
229
250
|
resolveToken,
|
|
251
|
+
// Absent with grant keys configured, vgi-rpc mints sealed grants itself.
|
|
230
252
|
mintGrant,
|
|
253
|
+
grantKeys: grantKeys ?? undefined,
|
|
231
254
|
// Only meaningful for `introspect_token`; a worker that mints but resolves
|
|
232
255
|
// nothing is not an oracle and needs no allowlist.
|
|
233
256
|
introspectPrincipals: resolveToken ? resolveIntrospectPrincipals(hosting.introspectPrincipals, env) : undefined,
|
|
@@ -271,3 +294,34 @@ function resolveIntrospectPrincipals(
|
|
|
271
294
|
}
|
|
272
295
|
return principals;
|
|
273
296
|
}
|
|
297
|
+
|
|
298
|
+
function processEnv(): Record<string, string | undefined> | undefined {
|
|
299
|
+
return (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Collect `--grant-key KEY` / `--grant-key=KEY` (repeatable, first mints) from
|
|
304
|
+
* an argv, as a `GrantKeys` -- or `undefined` when none was passed, so the
|
|
305
|
+
* environment still applies. Audience and lifetime come from
|
|
306
|
+
* `VGI_RPC_GRANT_AUDIENCE` / `VGI_RPC_GRANT_MAX_TTL_SECONDS`.
|
|
307
|
+
*
|
|
308
|
+
* @throws Error A malformed key: a worker refuses to start.
|
|
309
|
+
*/
|
|
310
|
+
export function grantKeysFromArgv(
|
|
311
|
+
argv: readonly string[],
|
|
312
|
+
env: Record<string, string | undefined> | undefined = processEnv(),
|
|
313
|
+
): GrantKeys | undefined {
|
|
314
|
+
const keys: string[] = [];
|
|
315
|
+
for (let i = 0; i < argv.length; i++) {
|
|
316
|
+
const a = argv[i];
|
|
317
|
+
if (a === "--grant-key") {
|
|
318
|
+
if (i + 1 >= argv.length) throw new Error("--grant-key requires a value");
|
|
319
|
+
keys.push(argv[++i]);
|
|
320
|
+
} else if (a.startsWith("--grant-key=")) {
|
|
321
|
+
keys.push(a.slice("--grant-key=".length));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (keys.length === 0) return undefined;
|
|
325
|
+
const fromEnv = GrantKeys.fromEnv({ ...(env ?? {}), VGI_RPC_GRANT_KEYS: keys.join(",") });
|
|
326
|
+
return fromEnv ?? undefined;
|
|
327
|
+
}
|
package/src/serve-entry.ts
CHANGED
|
@@ -29,10 +29,11 @@ import {
|
|
|
29
29
|
observePeerIdentity,
|
|
30
30
|
peerIdentityPrimary,
|
|
31
31
|
type AuthenticateFn,
|
|
32
|
+
GrantKeys,
|
|
32
33
|
type PeerResolutionOptions,
|
|
33
34
|
} from "@query-farm/vgi-rpc";
|
|
34
35
|
import { createVgiFetch } from "./http/fetch.js";
|
|
35
|
-
import type
|
|
36
|
+
import { grantKeysFromArgv, type HostingOptions } from "./rpc-server.js";
|
|
36
37
|
|
|
37
38
|
/** Environment variables `serveVgiWorker` reads. Injectable for testing. */
|
|
38
39
|
export interface ServeEnv {
|
|
@@ -40,6 +41,14 @@ export interface ServeEnv {
|
|
|
40
41
|
VGI_SIGNING_KEY?: string;
|
|
41
42
|
VGI_TOKEN_TTL?: string;
|
|
42
43
|
CORS_ORIGINS?: string;
|
|
44
|
+
/** Sealed-grant keys: comma-separated base64, exactly 32 bytes each, first mints. */
|
|
45
|
+
VGI_RPC_GRANT_KEYS?: string;
|
|
46
|
+
/** Audience bound into sealed grants. */
|
|
47
|
+
VGI_RPC_GRANT_AUDIENCE?: string;
|
|
48
|
+
/** Sealed-grant lifetime ceiling, seconds (default 7 days). */
|
|
49
|
+
VGI_RPC_GRANT_MAX_TTL_SECONDS?: string;
|
|
50
|
+
/** Principals allowed to call `introspect_token`, comma-separated. */
|
|
51
|
+
VGI_INTROSPECT_PRINCIPALS?: string;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
export const DEFAULT_PORT = 8787;
|
|
@@ -94,6 +103,10 @@ export interface ServeVgiWorkerOptions extends HostingOptions {
|
|
|
94
103
|
quiet?: boolean;
|
|
95
104
|
/** Environment source (default `process.env`). */
|
|
96
105
|
env?: ServeEnv;
|
|
106
|
+
/** Command-line arguments to read `--grant-key KEY` (repeatable, first mints)
|
|
107
|
+
* from. Default `process.argv.slice(2)`. A key here takes precedence over
|
|
108
|
+
* `VGI_RPC_GRANT_KEYS`; an explicit {@link grantKeys} over both. */
|
|
109
|
+
argv?: readonly string[];
|
|
97
110
|
/** Authenticates each request, returning the caller's `AuthContext`.
|
|
98
111
|
* Omit for an anonymous worker (the default). */
|
|
99
112
|
authenticate?: AuthenticateFn;
|
|
@@ -247,6 +260,11 @@ export function createVgiWorkerFetch(
|
|
|
247
260
|
mintGrant: opts.mintGrant,
|
|
248
261
|
introspectPrincipals: opts.introspectPrincipals,
|
|
249
262
|
maxAuthAge: opts.maxAuthAge,
|
|
263
|
+
grantKeys:
|
|
264
|
+
opts.grantKeys !== undefined
|
|
265
|
+
? opts.grantKeys
|
|
266
|
+
: (grantKeysFromArgv(opts.argv ?? process.argv.slice(2), env as Record<string, string | undefined>) ??
|
|
267
|
+
GrantKeys.fromEnv(env as Record<string, string | undefined>)),
|
|
250
268
|
});
|
|
251
269
|
}
|
|
252
270
|
|
package/src/worker-cf-entry.ts
CHANGED