@query-farm/vgi 0.39.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 CHANGED
@@ -615,6 +615,81 @@ never return `null` for one: both read as a definitive answer. Return `null` onl
615
615
  when the store answered and the credential is unknown; throw `GrantRefusedError` to
616
616
  decline a grant.
617
617
 
618
+ ### Sealed grants: minted credentials that log their owner in
619
+
620
+ `issue_grant` mints a credential meant for unattended automation to present later
621
+ as an ordinary bearer. Configure **grant keys** and the worker both mints and
622
+ accepts them, with no storage and no code:
623
+
624
+ ```console
625
+ $ VGI_RPC_GRANT_KEYS="$(openssl rand -base64 32)" bun run scripts/serve.ts
626
+ # or: bun run scripts/serve.ts --grant-key "$(openssl rand -base64 32)"
627
+ ```
628
+
629
+ - `VGI_RPC_GRANT_KEYS`: comma-separated standard base64, exactly 32 bytes each. The
630
+ first mints; every key verifies. Rotate by adding the new key first and removing
631
+ the old one once its grants have expired. `--grant-key KEY` (repeatable) on
632
+ `serveVgiWorker`, or the `grantKeys` option (`GrantKeys.parse([...])`, required on
633
+ Cloudflare Workers, where there is no process environment) work too.
634
+ - Optional `VGI_RPC_GRANT_AUDIENCE` (bind grants to this deployment) and
635
+ `VGI_RPC_GRANT_MAX_TTL_SECONDS` (default 7 days).
636
+ - With keys set, HTTP hosts `issue_grant` (minted by the framework unless you supply
637
+ `mintGrant`) and the worker's HTTP authentication accepts `vgig1.` grants after
638
+ your own `authenticate`, as the grant's owner (domain `grant`). A caller
639
+ authenticated by a grant has no `auth_time`, so it cannot mint another grant.
640
+ - A worker that supplies `resolveToken` also has bearers your `authenticate` did not
641
+ accept resolved through it (domain `token`); an `AuthUnavailableError` from the hook
642
+ is a 503 with `Retry-After`, never a 401.
643
+ - Your `authenticate` must throw (not return anonymous) for a bearer it does not
644
+ recognise, or the chain ends before grants are tried.
645
+ - No key set, nothing changes. A malformed key stops the worker at startup.
646
+ - Sealed grants are not individually revocable: keep the lifetime short; removing a
647
+ key revokes every grant it minted.
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
+
618
693
  ## Runtimes & entry points
619
694
 
620
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>;
@@ -4640,4 +4640,4 @@ export {
4640
4640
  typeSignature
4641
4641
  };
4642
4642
 
4643
- //# debugId=A700197CD6EACFA764756E2164756E21
4643
+ //# debugId=62AEFEFC6DBD059D64756E2164756E21