@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/mtls.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { createHash, X509Certificate } from "node:crypto";
|
|
5
|
+
import { AuthContext } from "../auth.js";
|
|
6
|
+
import type { AuthenticateFn } from "./auth.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// XFCC types and parser (no crypto needed)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/** A single element from an `x-forwarded-client-cert` header. */
|
|
13
|
+
export interface XfccElement {
|
|
14
|
+
hash: string | null;
|
|
15
|
+
cert: string | null;
|
|
16
|
+
subject: string | null;
|
|
17
|
+
uri: string | null;
|
|
18
|
+
dns: readonly string[];
|
|
19
|
+
by: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Receives a parsed XFCC element, returns an AuthContext on success. Must throw on failure. */
|
|
23
|
+
export type XfccValidateFn = (element: XfccElement) => AuthContext | Promise<AuthContext>;
|
|
24
|
+
|
|
25
|
+
/** Receives a parsed X509Certificate, returns an AuthContext on success. Must throw on failure. */
|
|
26
|
+
export type CertValidateFn = (cert: X509Certificate) => AuthContext | Promise<AuthContext>;
|
|
27
|
+
|
|
28
|
+
function splitRespectingQuotes(text: string, delimiter: string): string[] {
|
|
29
|
+
const parts: string[] = [];
|
|
30
|
+
let current: string[] = [];
|
|
31
|
+
let inQuotes = false;
|
|
32
|
+
let i = 0;
|
|
33
|
+
while (i < text.length) {
|
|
34
|
+
const ch = text[i];
|
|
35
|
+
if (ch === '"') {
|
|
36
|
+
inQuotes = !inQuotes;
|
|
37
|
+
current.push(ch);
|
|
38
|
+
} else if (ch === "\\" && inQuotes && i + 1 < text.length) {
|
|
39
|
+
current.push(ch);
|
|
40
|
+
current.push(text[i + 1]);
|
|
41
|
+
i++;
|
|
42
|
+
} else if (ch === delimiter && !inQuotes) {
|
|
43
|
+
parts.push(current.join(""));
|
|
44
|
+
current = [];
|
|
45
|
+
} else {
|
|
46
|
+
current.push(ch);
|
|
47
|
+
}
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
parts.push(current.join(""));
|
|
51
|
+
return parts;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function unescapeQuoted(text: string): string {
|
|
55
|
+
return text.replace(/\\(.)/g, "$1");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Extract the CN value from an RFC 4514 or similar DN string. */
|
|
59
|
+
function extractCn(subject: string): string {
|
|
60
|
+
for (const part of subject.split(/(?<!\\),/)) {
|
|
61
|
+
const trimmed = part.trim();
|
|
62
|
+
if (trimmed.toUpperCase().startsWith("CN=")) {
|
|
63
|
+
return trimmed.slice(3);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse an `x-forwarded-client-cert` header value.
|
|
71
|
+
*
|
|
72
|
+
* Handles comma-separated elements (respecting quoted values),
|
|
73
|
+
* semicolon-separated key=value pairs within each element, and
|
|
74
|
+
* URL-encoded Cert/URI/By fields.
|
|
75
|
+
*/
|
|
76
|
+
export function parseXfcc(headerValue: string): XfccElement[] {
|
|
77
|
+
const elements: XfccElement[] = [];
|
|
78
|
+
for (const rawElement of splitRespectingQuotes(headerValue, ",")) {
|
|
79
|
+
const trimmed = rawElement.trim();
|
|
80
|
+
if (!trimmed) continue;
|
|
81
|
+
const pairs = splitRespectingQuotes(trimmed, ";");
|
|
82
|
+
const fields: Record<string, string | string[]> = {};
|
|
83
|
+
for (const pair of pairs) {
|
|
84
|
+
const p = pair.trim();
|
|
85
|
+
if (!p) continue;
|
|
86
|
+
const eqIdx = p.indexOf("=");
|
|
87
|
+
if (eqIdx < 0) continue;
|
|
88
|
+
const key = p.slice(0, eqIdx).trim().toLowerCase();
|
|
89
|
+
let value = p.slice(eqIdx + 1).trim();
|
|
90
|
+
if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
|
|
91
|
+
value = unescapeQuoted(value.slice(1, -1));
|
|
92
|
+
}
|
|
93
|
+
if (key === "cert" || key === "uri" || key === "by") {
|
|
94
|
+
value = decodeURIComponent(value);
|
|
95
|
+
}
|
|
96
|
+
if (key === "dns") {
|
|
97
|
+
const existing = fields.dns;
|
|
98
|
+
if (Array.isArray(existing)) {
|
|
99
|
+
existing.push(value);
|
|
100
|
+
} else {
|
|
101
|
+
fields.dns = [value];
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
fields[key] = value;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const dns = Array.isArray(fields.dns) ? fields.dns : [];
|
|
108
|
+
elements.push({
|
|
109
|
+
hash: typeof fields.hash === "string" ? fields.hash : null,
|
|
110
|
+
cert: typeof fields.cert === "string" ? fields.cert : null,
|
|
111
|
+
subject: typeof fields.subject === "string" ? fields.subject : null,
|
|
112
|
+
uri: typeof fields.uri === "string" ? fields.uri : null,
|
|
113
|
+
dns,
|
|
114
|
+
by: typeof fields.by === "string" ? fields.by : null,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return elements;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create an authenticate callback from Envoy `x-forwarded-client-cert`.
|
|
122
|
+
*
|
|
123
|
+
* Parses the `x-forwarded-client-cert` header and extracts client identity.
|
|
124
|
+
* Does not require any crypto dependencies.
|
|
125
|
+
*
|
|
126
|
+
* **Warning:** The reverse proxy MUST strip client-supplied
|
|
127
|
+
* `x-forwarded-client-cert` headers before forwarding.
|
|
128
|
+
*/
|
|
129
|
+
export function mtlsAuthenticateXfcc(options?: {
|
|
130
|
+
validate?: XfccValidateFn;
|
|
131
|
+
domain?: string;
|
|
132
|
+
selectElement?: "first" | "last";
|
|
133
|
+
}): AuthenticateFn {
|
|
134
|
+
const validate = options?.validate;
|
|
135
|
+
const domain = options?.domain ?? "mtls";
|
|
136
|
+
const selectElement = options?.selectElement ?? "first";
|
|
137
|
+
|
|
138
|
+
return async function authenticate(request: Request): Promise<AuthContext> {
|
|
139
|
+
const headerValue = request.headers.get("x-forwarded-client-cert");
|
|
140
|
+
if (!headerValue) {
|
|
141
|
+
throw new Error("Missing x-forwarded-client-cert header");
|
|
142
|
+
}
|
|
143
|
+
const elements = parseXfcc(headerValue);
|
|
144
|
+
if (elements.length === 0) {
|
|
145
|
+
throw new Error("Empty x-forwarded-client-cert header");
|
|
146
|
+
}
|
|
147
|
+
const element = selectElement === "first" ? elements[0] : elements[elements.length - 1];
|
|
148
|
+
if (validate) {
|
|
149
|
+
return validate(element);
|
|
150
|
+
}
|
|
151
|
+
const principal = element.subject ? extractCn(element.subject) : "";
|
|
152
|
+
const claims: Record<string, any> = {};
|
|
153
|
+
if (element.hash) claims.hash = element.hash;
|
|
154
|
+
if (element.subject) claims.subject = element.subject;
|
|
155
|
+
if (element.uri) claims.uri = element.uri;
|
|
156
|
+
if (element.dns.length > 0) claims.dns = [...element.dns];
|
|
157
|
+
if (element.by) claims.by = element.by;
|
|
158
|
+
return new AuthContext(domain, true, principal, claims);
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// PEM-based factories (uses node:crypto X509Certificate)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
function parseCertFromHeader(request: Request, header: string): X509Certificate {
|
|
167
|
+
const raw = request.headers.get(header);
|
|
168
|
+
if (!raw) {
|
|
169
|
+
throw new Error(`Missing ${header} header`);
|
|
170
|
+
}
|
|
171
|
+
const pemStr = decodeURIComponent(raw);
|
|
172
|
+
if (!pemStr.startsWith("-----BEGIN CERTIFICATE-----")) {
|
|
173
|
+
throw new Error("Header value is not a PEM certificate");
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
return new X509Certificate(pemStr);
|
|
177
|
+
} catch (exc) {
|
|
178
|
+
throw new Error(`Failed to parse PEM certificate: ${exc}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function checkCertExpiry(cert: X509Certificate): void {
|
|
183
|
+
const now = new Date();
|
|
184
|
+
const notBefore = new Date(cert.validFrom);
|
|
185
|
+
const notAfter = new Date(cert.validTo);
|
|
186
|
+
if (now < notBefore) {
|
|
187
|
+
throw new Error("Certificate is not yet valid");
|
|
188
|
+
}
|
|
189
|
+
if (now > notAfter) {
|
|
190
|
+
throw new Error("Certificate has expired");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Create an mTLS authenticate callback with custom certificate validation.
|
|
196
|
+
*
|
|
197
|
+
* Generic factory that parses the client certificate from a proxy header
|
|
198
|
+
* and delegates identity extraction to a user-supplied `validate` callback.
|
|
199
|
+
*
|
|
200
|
+
* **Warning:** The reverse proxy MUST strip client-supplied certificate
|
|
201
|
+
* headers before forwarding.
|
|
202
|
+
*/
|
|
203
|
+
export function mtlsAuthenticate(options: {
|
|
204
|
+
validate: CertValidateFn;
|
|
205
|
+
header?: string;
|
|
206
|
+
checkExpiry?: boolean;
|
|
207
|
+
}): AuthenticateFn {
|
|
208
|
+
const { validate, header = "X-SSL-Client-Cert", checkExpiry = false } = options;
|
|
209
|
+
|
|
210
|
+
return async function authenticate(request: Request): Promise<AuthContext> {
|
|
211
|
+
const cert = parseCertFromHeader(request, header);
|
|
212
|
+
if (checkExpiry) {
|
|
213
|
+
checkCertExpiry(cert);
|
|
214
|
+
}
|
|
215
|
+
return validate(cert);
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const SUPPORTED_ALGORITHMS = new Set(["sha256", "sha1", "sha384", "sha512"]);
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Create an mTLS authenticate callback using certificate fingerprint lookup.
|
|
223
|
+
*
|
|
224
|
+
* Computes the certificate fingerprint and looks it up in the provided
|
|
225
|
+
* mapping. Fingerprints must be lowercase hex without colons.
|
|
226
|
+
*/
|
|
227
|
+
export function mtlsAuthenticateFingerprint(options: {
|
|
228
|
+
fingerprints: ReadonlyMap<string, AuthContext> | Record<string, AuthContext>;
|
|
229
|
+
header?: string;
|
|
230
|
+
algorithm?: string;
|
|
231
|
+
domain?: string;
|
|
232
|
+
checkExpiry?: boolean;
|
|
233
|
+
}): AuthenticateFn {
|
|
234
|
+
const { fingerprints, header, algorithm = "sha256", checkExpiry } = options;
|
|
235
|
+
if (!SUPPORTED_ALGORITHMS.has(algorithm)) {
|
|
236
|
+
throw new Error(`Unsupported hash algorithm: ${algorithm}`);
|
|
237
|
+
}
|
|
238
|
+
const entries: ReadonlyMap<string, AuthContext> =
|
|
239
|
+
fingerprints instanceof Map ? fingerprints : new Map(Object.entries(fingerprints));
|
|
240
|
+
|
|
241
|
+
function validate(cert: X509Certificate): AuthContext {
|
|
242
|
+
const fp = createHash(algorithm).update(cert.raw).digest("hex");
|
|
243
|
+
const ctx = entries.get(fp);
|
|
244
|
+
if (!ctx) {
|
|
245
|
+
throw new Error(`Unknown certificate fingerprint: ${fp}`);
|
|
246
|
+
}
|
|
247
|
+
return ctx;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return mtlsAuthenticate({ validate, header, checkExpiry });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Create an mTLS authenticate callback using certificate subject CN.
|
|
255
|
+
*
|
|
256
|
+
* Extracts the Subject Common Name as `principal` and populates
|
|
257
|
+
* `claims` with the full DN, serial number (hex), and `not_valid_after`.
|
|
258
|
+
*/
|
|
259
|
+
export function mtlsAuthenticateSubject(options?: {
|
|
260
|
+
header?: string;
|
|
261
|
+
domain?: string;
|
|
262
|
+
allowedSubjects?: ReadonlySet<string> | null;
|
|
263
|
+
checkExpiry?: boolean;
|
|
264
|
+
}): AuthenticateFn {
|
|
265
|
+
const { header, domain = "mtls", allowedSubjects = null, checkExpiry } = options ?? {};
|
|
266
|
+
|
|
267
|
+
function validate(cert: X509Certificate): AuthContext {
|
|
268
|
+
// Node's cert.subject is \n-separated "KEY=value" lines
|
|
269
|
+
const subjectParts = cert.subject
|
|
270
|
+
.split("\n")
|
|
271
|
+
.map((s) => s.trim())
|
|
272
|
+
.filter(Boolean);
|
|
273
|
+
const subjectDn = subjectParts.join(", ");
|
|
274
|
+
|
|
275
|
+
let cn = "";
|
|
276
|
+
for (const part of subjectParts) {
|
|
277
|
+
if (part.toUpperCase().startsWith("CN=")) {
|
|
278
|
+
cn = part.slice(3);
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (allowedSubjects !== null && !allowedSubjects.has(cn)) {
|
|
284
|
+
throw new Error(`Subject CN '${cn}' not in allowed subjects`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const serialHex = BigInt(`0x${cert.serialNumber}`).toString(16);
|
|
288
|
+
const notValidAfter = new Date(cert.validTo).toISOString();
|
|
289
|
+
|
|
290
|
+
return new AuthContext(domain, true, cn, {
|
|
291
|
+
subject_dn: subjectDn,
|
|
292
|
+
serial: serialHex,
|
|
293
|
+
not_valid_after: notValidAfter,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return mtlsAuthenticate({ validate, header, checkExpiry });
|
|
298
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pre-rendered HTML pages for the vgi-rpc HTTP server.
|
|
6
|
+
* Matches the styling of the Python and Go implementations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MethodDefinition } from "../types.js";
|
|
10
|
+
|
|
11
|
+
const LOGO_URL = "https://vgi-rpc-python.query.farm/assets/logo-hero.png";
|
|
12
|
+
|
|
13
|
+
const FONTS = `<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
14
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
15
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">`;
|
|
16
|
+
|
|
17
|
+
function escapeHtml(s: string): string {
|
|
18
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function arrowTypeToString(type: import("@query-farm/apache-arrow").DataType): string {
|
|
22
|
+
const id = type.typeId;
|
|
23
|
+
// Match the human-friendly type names used by the Python reference implementation
|
|
24
|
+
if (id === 5) return "str"; // Utf8
|
|
25
|
+
if (id === 4) return "bytes"; // Binary
|
|
26
|
+
if (id === 2) return "int"; // Int32/Int64
|
|
27
|
+
if (id === 3) return "float"; // Float32/Float64
|
|
28
|
+
if (id === 6) return "bool"; // Bool
|
|
29
|
+
if (id === 12) return "list"; // List
|
|
30
|
+
if (id === 17) return "map"; // Map
|
|
31
|
+
if (id === 24) return "enum"; // Dictionary
|
|
32
|
+
return type.toString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Landing page
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export function buildLandingPage(
|
|
40
|
+
protocolName: string,
|
|
41
|
+
serverId: string,
|
|
42
|
+
describePath: string | null,
|
|
43
|
+
repoUrl: string | null,
|
|
44
|
+
): string {
|
|
45
|
+
const links: string[] = [];
|
|
46
|
+
if (describePath) {
|
|
47
|
+
links.push(`<a class="primary" href="${escapeHtml(describePath)}">View service API</a>`);
|
|
48
|
+
}
|
|
49
|
+
if (repoUrl) {
|
|
50
|
+
links.push(`<a href="${escapeHtml(repoUrl)}">Source repository</a>`);
|
|
51
|
+
}
|
|
52
|
+
links.push(`<a href="https://vgi-rpc.query.farm">Learn more about <code>vgi-rpc</code></a>`);
|
|
53
|
+
|
|
54
|
+
return `<!DOCTYPE html>
|
|
55
|
+
<html lang="en">
|
|
56
|
+
<head>
|
|
57
|
+
<meta charset="utf-8">
|
|
58
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
59
|
+
<title>${escapeHtml(protocolName)} \u2014 vgi-rpc</title>
|
|
60
|
+
${FONTS}
|
|
61
|
+
<style>
|
|
62
|
+
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; max-width: 600px;
|
|
63
|
+
margin: 0 auto; padding: 60px 20px 0; color: #2c2c1e; text-align: center;
|
|
64
|
+
background: #faf8f0; }
|
|
65
|
+
.logo { margin-bottom: 24px; }
|
|
66
|
+
.logo img { width: 140px; height: 140px; border-radius: 50%;
|
|
67
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.12); }
|
|
68
|
+
h1 { color: #2d5016; margin-bottom: 8px; font-weight: 700; }
|
|
69
|
+
code { font-family: 'JetBrains Mono', monospace; background: #f0ece0;
|
|
70
|
+
padding: 2px 6px; border-radius: 3px; font-size: 0.9em; color: #2c2c1e; }
|
|
71
|
+
a { color: #2d5016; text-decoration: none; }
|
|
72
|
+
a:hover { color: #4a7c23; }
|
|
73
|
+
p { line-height: 1.7; color: #6b6b5a; }
|
|
74
|
+
.meta { font-size: 0.9em; color: #6b6b5a; }
|
|
75
|
+
.links { margin-top: 28px; display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
|
|
76
|
+
.links a { display: inline-block; padding: 8px 18px; border-radius: 6px;
|
|
77
|
+
border: 1px solid #4a7c23; color: #2d5016; font-weight: 600;
|
|
78
|
+
font-size: 0.9em; transition: all 0.2s ease; }
|
|
79
|
+
.links a:hover { background: #4a7c23; color: #fff; }
|
|
80
|
+
.links a.primary { background: #2d5016; color: #fff; border-color: #2d5016; }
|
|
81
|
+
.links a.primary:hover { background: #4a7c23; border-color: #4a7c23; }
|
|
82
|
+
footer { margin-top: 48px; padding: 20px 0; border-top: 1px solid #f0ece0;
|
|
83
|
+
color: #6b6b5a; font-size: 0.85em; }
|
|
84
|
+
footer a { color: #2d5016; font-weight: 600; }
|
|
85
|
+
footer a:hover { color: #4a7c23; }
|
|
86
|
+
</style>
|
|
87
|
+
</head>
|
|
88
|
+
<body>
|
|
89
|
+
<div class="logo">
|
|
90
|
+
<img src="${LOGO_URL}" alt="vgi-rpc logo">
|
|
91
|
+
</div>
|
|
92
|
+
<h1>${escapeHtml(protocolName)}</h1>
|
|
93
|
+
<p class="meta">Powered by <code>vgi-rpc</code> (TypeScript) · server <code>${escapeHtml(serverId)}</code></p>
|
|
94
|
+
<p>This is a <code>vgi-rpc</code> service endpoint.</p>
|
|
95
|
+
<div class="links">
|
|
96
|
+
${links.join("\n")}
|
|
97
|
+
</div>
|
|
98
|
+
<footer>
|
|
99
|
+
© 2026 🚜 <a href="https://query.farm">Query.Farm LLC</a>
|
|
100
|
+
</footer>
|
|
101
|
+
</body>
|
|
102
|
+
</html>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// 404 page
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export function buildNotFoundPage(prefix: string, protocolName: string): string {
|
|
110
|
+
const nameFragment = protocolName ? ` (<strong>${escapeHtml(protocolName)}</strong>)` : "";
|
|
111
|
+
const prefixDisplay = prefix || "/";
|
|
112
|
+
return `<!DOCTYPE html>
|
|
113
|
+
<html lang="en">
|
|
114
|
+
<head>
|
|
115
|
+
<meta charset="utf-8">
|
|
116
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
117
|
+
<title>404 \u2014 vgi-rpc endpoint</title>
|
|
118
|
+
<style>
|
|
119
|
+
body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px;
|
|
120
|
+
margin: 60px auto; padding: 0 20px; color: #333; text-align: center; }
|
|
121
|
+
.logo { margin-bottom: 24px; }
|
|
122
|
+
.logo img { width: 120px; height: 120px; }
|
|
123
|
+
h1 { color: #555; }
|
|
124
|
+
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.95em; }
|
|
125
|
+
a { color: #0066cc; }
|
|
126
|
+
p { line-height: 1.6; }
|
|
127
|
+
</style>
|
|
128
|
+
</head>
|
|
129
|
+
<body>
|
|
130
|
+
<div class="logo">
|
|
131
|
+
<img src="${LOGO_URL}" alt="vgi-rpc logo">
|
|
132
|
+
</div>
|
|
133
|
+
<h1>404 \u2014 Not Found</h1>
|
|
134
|
+
<p>This is a <code>vgi-rpc</code> service endpoint${nameFragment}.</p>
|
|
135
|
+
<p>RPC methods are available under <code>${escapeHtml(prefixDisplay)}/<method></code>.</p>
|
|
136
|
+
<p>Learn more at <a href="https://vgi-rpc.query.farm">vgi-rpc.query.farm</a>.</p>
|
|
137
|
+
</body>
|
|
138
|
+
</html>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Describe / API reference page
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function buildMethodCard(method: MethodDefinition): string {
|
|
146
|
+
const name = escapeHtml(method.name);
|
|
147
|
+
const isUnary = method.type === "unary";
|
|
148
|
+
const hasHeader = !!method.headerSchema;
|
|
149
|
+
|
|
150
|
+
// Badges — match Python reference (unary/stream/header only)
|
|
151
|
+
const badges: string[] = [];
|
|
152
|
+
badges.push(
|
|
153
|
+
isUnary ? `<span class="badge badge-unary">unary</span>` : `<span class="badge badge-stream">stream</span>`,
|
|
154
|
+
);
|
|
155
|
+
if (hasHeader) badges.push(`<span class="badge badge-header">header</span>`);
|
|
156
|
+
|
|
157
|
+
// Parameters table
|
|
158
|
+
let paramsHtml = "";
|
|
159
|
+
const paramsSchema = method.paramsSchema;
|
|
160
|
+
if (paramsSchema.fields.length > 0) {
|
|
161
|
+
const rows = paramsSchema.fields.map((f) => {
|
|
162
|
+
const paramName = escapeHtml(f.name);
|
|
163
|
+
const paramType = escapeHtml(arrowTypeToString(f.type));
|
|
164
|
+
const defaultVal =
|
|
165
|
+
method.defaults && f.name in method.defaults ? escapeHtml(JSON.stringify(method.defaults[f.name])) : "—";
|
|
166
|
+
return `<tr><td><code>${paramName}</code></td><td><code>${paramType}</code></td><td>${defaultVal}</td><td>—</td></tr>`;
|
|
167
|
+
});
|
|
168
|
+
paramsHtml = `<div class="section-label">Parameters</div>
|
|
169
|
+
<table><tr><th>Name</th><th>Type</th><th>Default</th><th>Description</th></tr>
|
|
170
|
+
${rows.join("\n")}
|
|
171
|
+
</table>`;
|
|
172
|
+
} else {
|
|
173
|
+
paramsHtml = `<p class="no-params">No parameters</p>`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Returns table (unary only)
|
|
177
|
+
let returnsHtml = "";
|
|
178
|
+
if (isUnary && method.resultSchema.fields.length > 0) {
|
|
179
|
+
const rows = method.resultSchema.fields.map((f) => {
|
|
180
|
+
return `<tr><td><code>${escapeHtml(f.name)}</code></td><td><code>${escapeHtml(arrowTypeToString(f.type))}</code></td></tr>`;
|
|
181
|
+
});
|
|
182
|
+
returnsHtml = `<div class="section-label">Returns</div>
|
|
183
|
+
<table><tr><th>Name</th><th>Type</th></tr>
|
|
184
|
+
${rows.join("\n")}
|
|
185
|
+
</table>`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Header table (streams with headers)
|
|
189
|
+
let headerHtml = "";
|
|
190
|
+
if (hasHeader && method.headerSchema && method.headerSchema.fields.length > 0) {
|
|
191
|
+
const rows = method.headerSchema.fields.map((f) => {
|
|
192
|
+
return `<tr><td><code>${escapeHtml(f.name)}</code></td><td><code>${escapeHtml(arrowTypeToString(f.type))}</code></td></tr>`;
|
|
193
|
+
});
|
|
194
|
+
headerHtml = `<div class="section-label">Stream Header</div>
|
|
195
|
+
<table><tr><th>Name</th><th>Type</th></tr>
|
|
196
|
+
${rows.join("\n")}
|
|
197
|
+
</table>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Docstring
|
|
201
|
+
const docHtml = method.doc ? `<p class="docstring">${escapeHtml(method.doc)}</p>` : "";
|
|
202
|
+
|
|
203
|
+
return `<div class="card">
|
|
204
|
+
<div class="card-header">
|
|
205
|
+
<span class="method-name">${name}</span>
|
|
206
|
+
${badges.join("\n")}
|
|
207
|
+
</div>
|
|
208
|
+
${docHtml}
|
|
209
|
+
${paramsHtml}
|
|
210
|
+
${returnsHtml}
|
|
211
|
+
${headerHtml}
|
|
212
|
+
</div>`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function buildDescribePage(
|
|
216
|
+
protocolName: string,
|
|
217
|
+
serverId: string,
|
|
218
|
+
methods: Map<string, MethodDefinition>,
|
|
219
|
+
repoUrl: string | null,
|
|
220
|
+
): string {
|
|
221
|
+
const sortedMethods = [...methods.entries()]
|
|
222
|
+
.filter(([name]) => name !== "__describe__")
|
|
223
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
224
|
+
|
|
225
|
+
const cards = sortedMethods.map(([, method]) => buildMethodCard(method)).join("\n");
|
|
226
|
+
|
|
227
|
+
const repoLink = repoUrl ? ` · <a href="${escapeHtml(repoUrl)}">Source</a>` : "";
|
|
228
|
+
|
|
229
|
+
return `<!DOCTYPE html>
|
|
230
|
+
<html lang="en">
|
|
231
|
+
<head>
|
|
232
|
+
<meta charset="utf-8">
|
|
233
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
234
|
+
<title>${escapeHtml(protocolName)} API Reference \u2014 vgi-rpc</title>
|
|
235
|
+
${FONTS}
|
|
236
|
+
<style>
|
|
237
|
+
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; max-width: 900px;
|
|
238
|
+
margin: 0 auto; padding: 40px 20px 0; color: #2c2c1e; background: #faf8f0; }
|
|
239
|
+
.header { text-align: center; margin-bottom: 40px; }
|
|
240
|
+
.header .logo img { width: 80px; height: 80px; border-radius: 50%;
|
|
241
|
+
box-shadow: 0 3px 16px rgba(0,0,0,0.10); }
|
|
242
|
+
.header h1 { margin-bottom: 4px; color: #2d5016; font-weight: 700; }
|
|
243
|
+
.header .subtitle { color: #6b6b5a; font-size: 1.1em; margin-top: 0; }
|
|
244
|
+
.header .meta { color: #6b6b5a; font-size: 0.9em; }
|
|
245
|
+
.header .meta a { color: #2d5016; font-weight: 600; }
|
|
246
|
+
.header .meta a:hover { color: #4a7c23; }
|
|
247
|
+
code { font-family: 'JetBrains Mono', monospace; background: #f0ece0;
|
|
248
|
+
padding: 2px 6px; border-radius: 3px; font-size: 0.85em; color: #2c2c1e; }
|
|
249
|
+
a { color: #2d5016; text-decoration: none; }
|
|
250
|
+
a:hover { color: #4a7c23; }
|
|
251
|
+
.card { border: 1px solid #f0ece0; border-radius: 8px; padding: 20px;
|
|
252
|
+
margin-bottom: 16px; background: #fff; }
|
|
253
|
+
.card:hover { border-color: #c8a43a; }
|
|
254
|
+
.card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
|
255
|
+
.method-name { font-family: 'JetBrains Mono', monospace; font-size: 1.1em; font-weight: 600;
|
|
256
|
+
color: #2d5016; }
|
|
257
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px;
|
|
258
|
+
font-size: 0.75em; font-weight: 600; text-transform: uppercase;
|
|
259
|
+
letter-spacing: 0.03em; }
|
|
260
|
+
.badge-unary { background: #e8f5e0; color: #2d5016; }
|
|
261
|
+
.badge-stream { background: #e0ecf5; color: #1a4a6b; }
|
|
262
|
+
.badge-exchange { background: #f5e6f0; color: #6b234a; }
|
|
263
|
+
.badge-producer { background: #e0f0f5; color: #1a5a6b; }
|
|
264
|
+
.badge-header { background: #f5eee0; color: #6b4423; }
|
|
265
|
+
.docstring { color: #6b6b5a; font-size: 0.9em; margin-top: 0; }
|
|
266
|
+
table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
|
|
267
|
+
th { text-align: left; padding: 8px 10px; background: #f0ece0; color: #2c2c1e;
|
|
268
|
+
font-weight: 600; border-bottom: 2px solid #e0dcd0; }
|
|
269
|
+
td { padding: 8px 10px; border-bottom: 1px solid #f0ece0; }
|
|
270
|
+
td code { font-size: 0.85em; }
|
|
271
|
+
.no-params { color: #6b6b5a; font-style: italic; font-size: 0.9em; }
|
|
272
|
+
.section-label { font-size: 0.8em; font-weight: 600; text-transform: uppercase;
|
|
273
|
+
letter-spacing: 0.05em; color: #6b6b5a; margin-top: 14px;
|
|
274
|
+
margin-bottom: 6px; }
|
|
275
|
+
footer { text-align: center; margin-top: 48px; padding: 20px 0;
|
|
276
|
+
border-top: 1px solid #f0ece0; color: #6b6b5a; font-size: 0.85em; }
|
|
277
|
+
footer a { color: #2d5016; font-weight: 600; }
|
|
278
|
+
footer a:hover { color: #4a7c23; }
|
|
279
|
+
</style>
|
|
280
|
+
</head>
|
|
281
|
+
<body>
|
|
282
|
+
<div class="header">
|
|
283
|
+
<div class="logo">
|
|
284
|
+
<img src="${LOGO_URL}" alt="vgi-rpc logo">
|
|
285
|
+
</div>
|
|
286
|
+
<h1>${escapeHtml(protocolName)}</h1>
|
|
287
|
+
<p class="subtitle">API Reference</p>
|
|
288
|
+
<p class="meta">Powered by <code>vgi-rpc</code> (TypeScript) · server <code>${escapeHtml(serverId)}</code>${repoLink}</p>
|
|
289
|
+
</div>
|
|
290
|
+
${cards}
|
|
291
|
+
<footer>
|
|
292
|
+
<a href="https://vgi-rpc.query.farm">Learn more about <code>vgi-rpc</code></a>
|
|
293
|
+
·
|
|
294
|
+
© 2026 🚜 <a href="https://query.farm">Query.Farm LLC</a>
|
|
295
|
+
</footer>
|
|
296
|
+
</body>
|
|
297
|
+
</html>`;
|
|
298
|
+
}
|
package/src/http/types.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
+
import type { ExternalLocationConfig } from "../external.js";
|
|
5
|
+
import type { DispatchHook } from "../types.js";
|
|
6
|
+
import type { AuthenticateFn, OAuthResourceMetadata } from "./auth.js";
|
|
7
|
+
|
|
4
8
|
/** Configuration options for createHttpHandler(). */
|
|
5
9
|
export interface HttpHandlerOptions {
|
|
6
|
-
/** URL path prefix for all endpoints. Default: "
|
|
10
|
+
/** URL path prefix for all endpoints. Default: "" (root). */
|
|
7
11
|
prefix?: string;
|
|
8
12
|
/** HMAC-SHA256 signing key for state tokens. Random 32 bytes if omitted. */
|
|
9
13
|
signingKey?: Uint8Array;
|
|
@@ -22,6 +26,24 @@ export interface HttpHandlerOptions {
|
|
|
22
26
|
/** zstd compression level for responses (1-22). If set, responses are
|
|
23
27
|
* compressed when the client sends Accept-Encoding: zstd. */
|
|
24
28
|
compressionLevel?: number;
|
|
29
|
+
/** Optional authentication callback. Called for each request before dispatch. */
|
|
30
|
+
authenticate?: AuthenticateFn;
|
|
31
|
+
/** Optional RFC 9728 OAuth Protected Resource Metadata. Served at well-known endpoint. */
|
|
32
|
+
oauthResourceMetadata?: OAuthResourceMetadata;
|
|
33
|
+
/** Optional dispatch hook for observability (tracing, metrics). */
|
|
34
|
+
dispatchHook?: DispatchHook;
|
|
35
|
+
/** Enable HTML landing page at GET {prefix}/. Default: true. */
|
|
36
|
+
enableLandingPage?: boolean;
|
|
37
|
+
/** Enable HTML describe/API reference page at GET {prefix}/describe. Default: true. */
|
|
38
|
+
enableDescribePage?: boolean;
|
|
39
|
+
/** Enable HTML 404 page for unmatched GET routes. Default: true. */
|
|
40
|
+
enableNotFoundPage?: boolean;
|
|
41
|
+
/** Protocol name shown in HTML pages. Defaults to the Protocol's name. */
|
|
42
|
+
protocolName?: string;
|
|
43
|
+
/** URL to service's source repository, shown in landing/describe pages. */
|
|
44
|
+
repositoryUrl?: string;
|
|
45
|
+
/** External storage config for externalizing large response batches. */
|
|
46
|
+
externalLocation?: ExternalLocationConfig;
|
|
25
47
|
}
|
|
26
48
|
|
|
27
49
|
/** Serializer for stream state objects stored in state tokens. */
|