@query-farm/vgi 0.40.0 → 0.41.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 +44 -0
- package/dist/attach-ticket.d.ts +179 -0
- package/dist/catalog/attach-option.d.ts +30 -0
- package/dist/client-entry.js +1 -1
- package/dist/client-entry.js.map +3 -3
- package/dist/crypto.d.ts +5 -1
- package/dist/http/fetch.d.ts +6 -0
- package/dist/index.core.d.ts +2 -1
- package/dist/index.js +1937 -1447
- package/dist/index.js.map +15 -14
- package/dist/rpc-server.d.ts +14 -0
- package/dist/serve-entry.js +527 -10
- package/dist/serve-entry.js.map +10 -8
- package/dist/worker-cf-entry.js +573 -62
- package/dist/worker-cf-entry.js.map +11 -10
- package/dist/worker.d.ts +1 -0
- package/package.json +3 -3
- package/src/attach-ticket.ts +611 -0
- package/src/catalog/attach-option.ts +75 -0
- package/src/catalog/composite.ts +5 -1
- package/src/crypto.ts +13 -1
- package/src/http/fetch.ts +23 -1
- package/src/index.core.ts +27 -1
- package/src/protocol/handlers/catalog/admin.ts +21 -1
- package/src/rpc-server.ts +50 -5
- package/src/serve-entry.ts +4 -0
- package/src/worker.ts +13 -0
package/README.md
CHANGED
|
@@ -646,6 +646,50 @@ $ VGI_RPC_GRANT_KEYS="$(openssl rand -base64 32)" bun run scripts/serve.ts
|
|
|
646
646
|
- Sealed grants are not individually revocable: keep the lifetime short; removing a
|
|
647
647
|
key revokes every grant it minted.
|
|
648
648
|
|
|
649
|
+
### Attach tickets: reattach as a user without their options (`vgi.attach_tickets.v1`)
|
|
650
|
+
|
|
651
|
+
A grant says *who* attaches; an **attach ticket** says *what*. While a user is
|
|
652
|
+
attached and logged in, the client asks the worker to seal the options it attached
|
|
653
|
+
with, secret ones included, into a ticket only this worker can open. A runner holding
|
|
654
|
+
that user's grant later reattaches with one option and never sees the others:
|
|
655
|
+
|
|
656
|
+
```sql
|
|
657
|
+
-- as the runner, authenticated by `Authorization: Bearer <grant>`
|
|
658
|
+
ATTACH 'ticket_probe' AS p (TYPE vgi, LOCATION 'https://worker.example.com',
|
|
659
|
+
vgi_attach_ticket 'vgia1.…');
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
- **Hosting.** `seal_attach(request: SealAttachRequest) -> AttachTicket` is hosted on
|
|
663
|
+
**HTTP only**, and only when both halves of an unattended session can be issued:
|
|
664
|
+
`VGI_SIGNING_KEY` (or the `signingKey` option) is configured explicitly — the
|
|
665
|
+
generated fallback key never counts, since every ticket would die on restart — and
|
|
666
|
+
the worker can issue grants (grant keys configured, or its own `mintGrant`).
|
|
667
|
+
Otherwise the protocol is absent, so a client learns this from reflection.
|
|
668
|
+
`createVgiFetch` (Cloudflare Workers) requires its key and so counts it as
|
|
669
|
+
configured; pass `signingKeyConfigured: false` for a key you generate per isolate.
|
|
670
|
+
- **Sealing.** The caller must be authenticated (anonymous is `action_denied`); no
|
|
671
|
+
fresh login is needed. Options are checked against the catalog's declared attach
|
|
672
|
+
options (`invalid_request` with one `BadRequest` violation per field). The lifetime
|
|
673
|
+
is capped at the grant maximum (`VGI_RPC_GRANT_MAX_TTL_SECONDS`, default 7 days with
|
|
674
|
+
grant keys); `ttl_seconds: 0` asks for the maximum.
|
|
675
|
+
- **Redeeming.** `catalog_attach` redeems a `vgi_attach_ticket` option (any letter
|
|
676
|
+
case) before any catalog code runs: it must be the only option (`invalid_request`),
|
|
677
|
+
it opens only under the caller's principal and this worker's key
|
|
678
|
+
(`attach_ticket_invalid`) and only within its lifetime (`attach_ticket_expired`),
|
|
679
|
+
and the attach then proceeds exactly as the user typed it — the sealed catalog name,
|
|
680
|
+
options and version specs. A `CompositeCatalogInterface` routes on the sealed name.
|
|
681
|
+
The ticket binds the principal only, not the login domain, so a ticket sealed under
|
|
682
|
+
a JWT login opens under that user's grant.
|
|
683
|
+
- **Reserved name.** No catalog may declare an attach option named
|
|
684
|
+
`vgi_attach_ticket` (any case); a worker that does refuses to serve.
|
|
685
|
+
- Rotating `VGI_SIGNING_KEY` invalidates every ticket. A ticket carries no authority:
|
|
686
|
+
without a grant or login for the same principal it attaches nothing. Neither the
|
|
687
|
+
ticket nor a restored option is ever logged.
|
|
688
|
+
|
|
689
|
+
Normative spec: vgi-python `docs/protocol/vgi-attach-tickets.md`. The example HTTP
|
|
690
|
+
worker serves the cross-SDK `ticket_probe` fixture catalog and hosts tickets when
|
|
691
|
+
started with both `VGI_SIGNING_KEY` and `VGI_RPC_GRANT_KEYS`.
|
|
692
|
+
|
|
649
693
|
## Runtimes & entry points
|
|
650
694
|
|
|
651
695
|
The package ships a backend-agnostic Arrow facade and selects an implementation at
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { type AuthContext, Protocol } from "@query-farm/vgi-rpc";
|
|
2
|
+
import type { CatalogInterface } from "./catalog/interface.js";
|
|
3
|
+
/** Token prefix. The version is in the prefix, so an incompatible format is a
|
|
4
|
+
* different prefix -- never half-parsed. */
|
|
5
|
+
export declare const ATTACH_TICKET_PREFIX = "vgia1.";
|
|
6
|
+
/** The reserved ATTACH option a runner presents a ticket in. */
|
|
7
|
+
export declare const ATTACH_TICKET_OPTION = "vgi_attach_ticket";
|
|
8
|
+
/** Wire name of the protocol hosting `seal_attach`. */
|
|
9
|
+
export declare const ATTACH_TICKETS_PROTOCOL_NAME = "vgi.attach_tickets.v1";
|
|
10
|
+
/** Its declared version. */
|
|
11
|
+
export declare const ATTACH_TICKETS_PROTOCOL_VERSION = "1.0.0";
|
|
12
|
+
/** The envelope's version byte, fixed by this format. */
|
|
13
|
+
export declare const TICKET_ENVELOPE_VERSION = 1;
|
|
14
|
+
/** Largest options record (serialized Arrow IPC bytes) a ticket may carry. */
|
|
15
|
+
export declare const MAX_OPTIONS_BYTES: number;
|
|
16
|
+
/** Longest ticket text considered at all. */
|
|
17
|
+
export declare const MAX_TICKET_CHARS: number;
|
|
18
|
+
/** Allowance for clocks disagreeing between the sealing and redeeming worker. */
|
|
19
|
+
export declare const CLOCK_SKEW_SECONDS = 60;
|
|
20
|
+
/**
|
|
21
|
+
* A `vgi_attach_ticket` this worker cannot accept.
|
|
22
|
+
*
|
|
23
|
+
* One type for every cause -- malformed, wrong prefix, non-canonical, wrong
|
|
24
|
+
* key, wrong principal, tampered, bad payload -- so a caller cannot tell a
|
|
25
|
+
* forged ticket from another user's. The message never contains the ticket.
|
|
26
|
+
*/
|
|
27
|
+
export declare class AttachTicketInvalidError extends Error {
|
|
28
|
+
static readonly errorKind = "attach_ticket_invalid";
|
|
29
|
+
readonly errorKind = "attach_ticket_invalid";
|
|
30
|
+
readonly errorCode = "INVALID_ARGUMENT";
|
|
31
|
+
readonly errorDetails: Record<string, unknown>[];
|
|
32
|
+
constructor(detail?: string);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* An authentic ticket outside its lifetime. Only raised once the ticket has
|
|
36
|
+
* opened under the caller's principal, so it reveals nothing a forger could use.
|
|
37
|
+
*/
|
|
38
|
+
export declare class AttachTicketExpiredError extends Error {
|
|
39
|
+
static readonly errorKind = "attach_ticket_expired";
|
|
40
|
+
readonly errorKind = "attach_ticket_expired";
|
|
41
|
+
readonly errorCode = "FAILED_PRECONDITION";
|
|
42
|
+
readonly errorDetails: Record<string, unknown>[];
|
|
43
|
+
constructor(detail?: string);
|
|
44
|
+
}
|
|
45
|
+
/** `invalid_request` / `INVALID_ARGUMENT` with one `BadRequest` violation per bad field. */
|
|
46
|
+
export declare class AttachTicketRequestError extends Error {
|
|
47
|
+
readonly errorKind = "invalid_request";
|
|
48
|
+
readonly errorCode = "INVALID_ARGUMENT";
|
|
49
|
+
readonly errorDetails: Record<string, unknown>[];
|
|
50
|
+
constructor(message: string, violations: ReadonlyArray<readonly [string, string]>);
|
|
51
|
+
}
|
|
52
|
+
/** `action_denied` / `PERMISSION_DENIED`, naming the refused action. */
|
|
53
|
+
export declare class SealAttachDeniedError extends Error {
|
|
54
|
+
readonly errorKind = "action_denied";
|
|
55
|
+
readonly errorCode = "PERMISSION_DENIED";
|
|
56
|
+
readonly errorDetails: Record<string, unknown>[];
|
|
57
|
+
constructor(message: string);
|
|
58
|
+
}
|
|
59
|
+
/** What a ticket carries. */
|
|
60
|
+
export interface AttachTicketClaims {
|
|
61
|
+
/** Seconds since the Unix epoch. */
|
|
62
|
+
issuedAt: number;
|
|
63
|
+
/** Seconds since the Unix epoch; `0` means no expiry. */
|
|
64
|
+
expiresAt: number;
|
|
65
|
+
/** 32 lowercase hex; a correlation handle, not a secret. */
|
|
66
|
+
ticketId: string;
|
|
67
|
+
catalogName: string;
|
|
68
|
+
/** `""` when the user gave none. */
|
|
69
|
+
dataVersionSpec: string;
|
|
70
|
+
/** `""` when the user gave none. */
|
|
71
|
+
implementationVersion: string;
|
|
72
|
+
/** Arrow IPC stream of the one-row options record; empty for none. */
|
|
73
|
+
optionsIpc: Uint8Array;
|
|
74
|
+
}
|
|
75
|
+
/** The ticket AAD: `"vgi.attach_ticket.v1" 0x00 || UTF-8(principal)`. */
|
|
76
|
+
export declare function attachTicketAad(principal: string): Uint8Array;
|
|
77
|
+
/** Encode a ticket payload (§2.1). Exported for the vectors' byte-exact
|
|
78
|
+
* `payload_hex` check; not part of the public API. @internal */
|
|
79
|
+
export declare function encodeAttachTicketPayload(c: AttachTicketClaims): Uint8Array;
|
|
80
|
+
/** Inputs to {@link mintAttachTicket}. */
|
|
81
|
+
export interface MintAttachTicketOptions {
|
|
82
|
+
principal: string;
|
|
83
|
+
catalogName: string;
|
|
84
|
+
optionsIpc: Uint8Array;
|
|
85
|
+
dataVersionSpec?: string;
|
|
86
|
+
implementationVersion?: string;
|
|
87
|
+
issuedAt: number;
|
|
88
|
+
expiresAt: number;
|
|
89
|
+
/** Override the random id -- for vectors only. */
|
|
90
|
+
ticketId?: string;
|
|
91
|
+
/** A fixed 24-byte nonce -- for vectors only. */
|
|
92
|
+
nonce?: Uint8Array;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Seal a ticket for `principal` under `signingKey` (the attach_opaque_data
|
|
96
|
+
* key; any length, normalized exactly as the attach envelope's is).
|
|
97
|
+
*
|
|
98
|
+
* @throws Error An empty principal or catalog, an empty lifetime, a bad
|
|
99
|
+
* ticket id, or a field too long to encode.
|
|
100
|
+
*/
|
|
101
|
+
export declare function mintAttachTicket(signingKey: Uint8Array, opts: MintAttachTicketOptions): Promise<{
|
|
102
|
+
token: string;
|
|
103
|
+
claims: AttachTicketClaims;
|
|
104
|
+
}>;
|
|
105
|
+
/**
|
|
106
|
+
* Verify a ticket for the calling `principal` and return what it carries.
|
|
107
|
+
*
|
|
108
|
+
* Order, normative: prefix, length, canonical base64url, caller, AEAD open
|
|
109
|
+
* under the caller's principal, strict payload parse, then lifetime with a
|
|
110
|
+
* 60 s skew. The lifetime is inside the ciphertext, so it is trusted only
|
|
111
|
+
* after the tag verified.
|
|
112
|
+
*
|
|
113
|
+
* @param now Override the clock (seconds), for tests and vectors.
|
|
114
|
+
* @throws AttachTicketInvalidError Any cause but expiry.
|
|
115
|
+
* @throws AttachTicketExpiredError Authentic but outside its lifetime.
|
|
116
|
+
*/
|
|
117
|
+
export declare function openAttachTicket(signingKey: Uint8Array, token: string, principal: string | null | undefined, now?: number): Promise<AttachTicketClaims>;
|
|
118
|
+
/** The catalog_attach request fields a ticket replaces. */
|
|
119
|
+
export interface RestoredAttach {
|
|
120
|
+
name: string;
|
|
121
|
+
/** Raw Arrow IPC options bytes, or `null` for none. */
|
|
122
|
+
optionsIpc: Uint8Array | null;
|
|
123
|
+
/** The same options, decoded. */
|
|
124
|
+
options: Record<string, unknown>;
|
|
125
|
+
dataVersionSpec: string | null;
|
|
126
|
+
implementationVersion: string | null;
|
|
127
|
+
}
|
|
128
|
+
/** The caller's principal, or `null` for anonymous. */
|
|
129
|
+
export declare function callerPrincipal(auth: AuthContext | undefined | null): string | null;
|
|
130
|
+
/**
|
|
131
|
+
* Replace a ticket-carrying attach with the attach it seals.
|
|
132
|
+
*
|
|
133
|
+
* Returns `null` when `options` carries no `vgi_attach_ticket` (the request is
|
|
134
|
+
* untouched). Otherwise the ticket must be the only option
|
|
135
|
+
* ({@link AttachTicketRequestError} -- checked before the ticket is opened),
|
|
136
|
+
* must open under the caller's principal and `signingKey`
|
|
137
|
+
* ({@link AttachTicketInvalidError}; no key, as off HTTP, never opens one),
|
|
138
|
+
* and must be within its lifetime ({@link AttachTicketExpiredError}).
|
|
139
|
+
* Never logs the ticket or a restored option.
|
|
140
|
+
*/
|
|
141
|
+
export declare function redeemAttachTicket(options: Record<string, unknown>, signingKey: Uint8Array | undefined | null, auth: AuthContext | undefined | null, now?: number): Promise<RestoredAttach | null>;
|
|
142
|
+
/** `SealAttachRequest`, field for field with vgi-python. */
|
|
143
|
+
export declare const SEAL_ATTACH_REQUEST_SCHEMA: import("./index.core.js").VgiSchema;
|
|
144
|
+
/** `AttachTicket`, field for field with vgi-python. */
|
|
145
|
+
export declare const ATTACH_TICKET_SCHEMA: import("./index.core.js").VgiSchema;
|
|
146
|
+
/**
|
|
147
|
+
* The ticket lifetime ceiling: the worker's grant maximum, or `null`.
|
|
148
|
+
*
|
|
149
|
+
* With grant keys configured it is their `maxTtlSeconds`. Otherwise (a worker
|
|
150
|
+
* minting its own grants) `VGI_RPC_GRANT_MAX_TTL_SECONDS` when set, else none.
|
|
151
|
+
*
|
|
152
|
+
* @throws Error The environment value is not a positive integer.
|
|
153
|
+
*/
|
|
154
|
+
export declare function resolveTicketMaxTtl(grantKeys: {
|
|
155
|
+
readonly maxTtlSeconds: number;
|
|
156
|
+
} | null | undefined, env: Record<string, string | undefined> | undefined): number | null;
|
|
157
|
+
/** What `seal_attach` needs from the worker. */
|
|
158
|
+
export interface AttachTicketsConfig {
|
|
159
|
+
/** The worker's signing key -- the key that seals attach_opaque_data. */
|
|
160
|
+
signingKey: Uint8Array;
|
|
161
|
+
/** The catalog(s) the worker serves; validation reads their declared attach options. */
|
|
162
|
+
catalogInterface: CatalogInterface | undefined;
|
|
163
|
+
/** Lifetime ceiling in seconds, or `null` for no maximum. */
|
|
164
|
+
maxTtlSeconds: number | null;
|
|
165
|
+
/** Clock override (seconds), for tests. */
|
|
166
|
+
now?: () => number;
|
|
167
|
+
}
|
|
168
|
+
/** Validate and seal; the body of `seal_attach`. */
|
|
169
|
+
export declare function sealAttach(config: AttachTicketsConfig, request: Record<string, unknown>, auth: AuthContext | undefined): Promise<{
|
|
170
|
+
ticket: string;
|
|
171
|
+
expires_at: number;
|
|
172
|
+
}>;
|
|
173
|
+
/**
|
|
174
|
+
* Build `vgi.attach_tickets.v1`: one unary method, `seal_attach(request:
|
|
175
|
+
* SealAttachRequest) -> AttachTicket`, each carried as serialized Arrow IPC
|
|
176
|
+
* in a single binary column -- the same wire, and protocol hash, as
|
|
177
|
+
* vgi-python's.
|
|
178
|
+
*/
|
|
179
|
+
export declare function buildAttachTicketsProtocol(config: AttachTicketsConfig): Protocol;
|
|
@@ -60,6 +60,18 @@ export interface AttachOptionSpec {
|
|
|
60
60
|
*/
|
|
61
61
|
secret?: boolean;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Reserved for attach tickets: the framework reads an ATTACH option with this
|
|
65
|
+
* name as a `vgi_attach_ticket` before any catalog code runs, so no catalog may
|
|
66
|
+
* declare an attach option called this, compared case-insensitively.
|
|
67
|
+
*/
|
|
68
|
+
export declare const RESERVED_ATTACH_OPTION = "vgi_attach_ticket";
|
|
69
|
+
/**
|
|
70
|
+
* Throw if any spec uses the reserved {@link RESERVED_ATTACH_OPTION} name.
|
|
71
|
+
*
|
|
72
|
+
* @throws Error naming the offending option.
|
|
73
|
+
*/
|
|
74
|
+
export declare function assertNoReservedAttachOption(specs: Iterable<Pick<AttachOptionSpec, "name">>): void;
|
|
63
75
|
/**
|
|
64
76
|
* Serialize an AttachOptionSpec to the wire format the extension expects
|
|
65
77
|
* (one IPC-serialized RecordBatch with a single row).
|
|
@@ -99,3 +111,21 @@ export declare class MissingAttachOptionsError extends Error {
|
|
|
99
111
|
* @throws {MissingAttachOptionsError} If a required option was not supplied.
|
|
100
112
|
*/
|
|
101
113
|
export declare function validateRequiredAttachOptions(catalogName: string, specs: Iterable<AttachOptionSpec>, options: Record<string, unknown>): void;
|
|
114
|
+
/** Raised when a catalog declares the reserved attach option name. */
|
|
115
|
+
export declare class ReservedAttachOptionError extends Error {
|
|
116
|
+
constructor(message: string);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Startup check: refuse a catalog that declares an attach option named
|
|
120
|
+
* {@link RESERVED_ATTACH_OPTION}, reading the specs it advertises through
|
|
121
|
+
* `catalogsInfo()`.
|
|
122
|
+
*
|
|
123
|
+
* TypeScript has no declaration-time hook for a plain spec object, so the
|
|
124
|
+
* worker entry points run this before serving. Only the reserved-name refusal
|
|
125
|
+
* propagates (as {@link ReservedAttachOptionError}); any other failure reading
|
|
126
|
+
* `catalogsInfo()` -- a catalog whose discovery needs I/O that is not up yet
|
|
127
|
+
* -- is left for discovery itself to report.
|
|
128
|
+
*/
|
|
129
|
+
export declare function checkReservedAttachOptions(catalog: {
|
|
130
|
+
catalogsInfo?(): unknown;
|
|
131
|
+
} | undefined): Promise<void>;
|
package/dist/client-entry.js
CHANGED