@openparachute/hub 0.7.5-rc.4 → 0.7.5

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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Surface DEPLOY tokens — the PAT-equivalent for the Surface Git Transport
3
+ * (Phase 3a, design doc 2026-06-30-surface-git-transport.md §6b).
4
+ *
5
+ * Phases 0a–2 gave the hub an authenticated `git http-backend` endpoint
6
+ * (`/git/<name>`) that validates `surface:<name>:read|write`, plus an in-framework
7
+ * grant flow that injects a per-turn token into an internal agent's `GIT_ASKPASS`.
8
+ * This module serves the OTHER actor: an EXTERNAL/remote git client — a
9
+ * `claude -p` agent (or any box) on a different machine — that just needs to hold
10
+ * a static secret and `git push`.
11
+ *
12
+ * The answer is a scoped, REGISTERED, REVOCABLE, LISTABLE token the operator
13
+ * mints and hands over: "a GitHub PAT, but for a parachute surface, git-native."
14
+ * It reuses the exact same mint (`signAccessToken`) + registered-mint discipline
15
+ * (`recordTokenMint` → the git endpoint's `validateAccessToken` accepts it, the
16
+ * revocation list kills it) as the agent-grant path ({@link mintSurfaceGrant} in
17
+ * admin-agent-grants.ts) — minus the approval flow, because the operator minting
18
+ * it on their own box IS the governance.
19
+ *
20
+ * Security posture (design §7): the token is scoped to ONE surface + one verb
21
+ * (read xor write), registered so the operator can list + revoke it (kill a
22
+ * leaked one, like GitHub PAT management), and fixed-TTL (default 90d — re-mint
23
+ * to renew). Blast radius is a deploy-key, not a master key: even a push builds
24
+ * in surface-host's sandbox (Phase 0c). The secret lives in the token the
25
+ * operator hands over — never persisted here beyond the registry row (which holds
26
+ * the jti + scope + expiry for revocation, NOT the token bytes).
27
+ *
28
+ * This is a pure library — no CLI I/O, no operator-token loading. The command
29
+ * (`commands/surface.ts`) does the operator-auth gate + argument parsing +
30
+ * output; these functions do the token mechanics against an open hub DB.
31
+ */
32
+ import type { Database } from "bun:sqlite";
33
+ import { SURFACE_NAME_RE } from "./git-registry.ts";
34
+ import {
35
+ findTokenRowByJti,
36
+ listTokens,
37
+ recordTokenMint,
38
+ revokeTokenByJti,
39
+ signAccessToken,
40
+ } from "./jwt-sign.ts";
41
+
42
+ /** A deploy token's authority — read (clone/fetch) xor write (push). Write ⊇ read
43
+ * at the git endpoint (a writer can always fetch), matching GitHub's model. */
44
+ export type SurfaceAccess = "read" | "write";
45
+
46
+ /**
47
+ * `created_via` tag stamped on deploy-token registry rows. The narrowing key for
48
+ * `listSurfaceTokens` / `revokeSurfaceToken` — so deploy tokens are managed as a
49
+ * distinct class from agent grants (`agent_grant`) and generic CLI mints
50
+ * (`cli_mint`), even though all three can carry a `surface:<name>:<verb>` scope.
51
+ */
52
+ export const SURFACE_TOKEN_CREATED_VIA = "surface_token" as const;
53
+
54
+ /** `sub` + registry `subject` for a deploy token — no hub user is tied to it (it's
55
+ * handed to an external client). Surfaces as `REMOTE_USER` in the git endpoint. */
56
+ export const SURFACE_TOKEN_SUBJECT = "surface-deploy";
57
+
58
+ /** `client_id` on a deploy token — distinguishes it in the registry / admin UI. */
59
+ export const SURFACE_TOKEN_CLIENT_ID = "parachute-surface-token";
60
+
61
+ /**
62
+ * Default deploy-token lifetime — 90 days, matching the surface/vault GRANT
63
+ * posture (long-lived-but-revocable; a headless client holds it, re-mint to
64
+ * renew). Long-lived is a convenience/exposure tradeoff — kept bounded + easily
65
+ * revocable rather than never-expiring (design §7 "consider a sane default TTL").
66
+ */
67
+ export const SURFACE_TOKEN_TTL_DEFAULT_SECONDS = 90 * 24 * 60 * 60;
68
+
69
+ /** Hard cap on `--ttl`/`--expires-in` — 365 days, matching `auth mint-token`. */
70
+ export const SURFACE_TOKEN_TTL_MAX_SECONDS = 365 * 24 * 60 * 60;
71
+
72
+ /**
73
+ * A `surface:<name>:<verb>` scope, verb ∈ {read, write}. The name group MUST
74
+ * stay in sync with `SURFACE_NAME_RE` (git-registry.ts) — it is the same
75
+ * kebab/alnum charset, inlined here (rather than composed from `.source`) to
76
+ * keep this a plain anchored literal. If `SURFACE_NAME_RE`'s charset ever
77
+ * widens (e.g. dots), widen this too or `listSurfaceTokens` will silently drop
78
+ * tokens whose name uses the new character.
79
+ */
80
+ const SURFACE_SCOPE_RE = /^surface:([a-zA-Z0-9][a-zA-Z0-9_-]{0,63}):(read|write)$/;
81
+
82
+ /** Build the canonical deploy-token scope string. */
83
+ export function surfaceScope(name: string, access: SurfaceAccess): string {
84
+ return `surface:${name}:${access}`;
85
+ }
86
+
87
+ export interface MintSurfaceTokenOpts {
88
+ /** Surface name — a `SURFACE_NAME_RE` slug (validated here; throws otherwise). */
89
+ name: string;
90
+ /** read (clone/fetch) or write (push). */
91
+ access: SurfaceAccess;
92
+ /** Hub origin → the token's `iss` (resolved by the caller via `resolveHubIssuer`). */
93
+ issuer: string;
94
+ /** Lifetime; defaults to {@link SURFACE_TOKEN_TTL_DEFAULT_SECONDS}. */
95
+ ttlSeconds?: number;
96
+ /**
97
+ * Optional hub user_id to tie the registry row to (the minting operator's
98
+ * user). The `sub`/`subject` stay {@link SURFACE_TOKEN_SUBJECT} regardless —
99
+ * the token is for an external client, not a hub user session.
100
+ */
101
+ userId?: string;
102
+ /** Test seam — defaults to the real `signAccessToken`. */
103
+ signToken?: typeof signAccessToken;
104
+ now?: () => Date;
105
+ }
106
+
107
+ export interface MintedSurfaceToken {
108
+ /** The JWT to hand the external client. NOT recoverable from the DB afterward. */
109
+ token: string;
110
+ jti: string;
111
+ expiresAt: string;
112
+ scope: string;
113
+ }
114
+
115
+ /**
116
+ * Mint a surface deploy token: a REGISTERED (`created_via: "surface_token"`)
117
+ * `surface:<name>:<verb>` JWT the git-transport endpoint validates (signature →
118
+ * hub keys, `iss` ∈ the multi-origin hub-bound set, revocation, then
119
+ * `scopes.includes("surface:<name>:<verb>")`). Mirrors {@link mintSurfaceGrant}
120
+ * — same registered-mint discipline so `revokeSurfaceToken` (and the revocation
121
+ * list) can kill it — minus the vault-only bits (no `vaultScope`, no
122
+ * `scoped_tags`). Audience is `surface.<name>` for symmetry with `vault.<name>`;
123
+ * the git endpoint doesn't check `aud` (it keys purely off the URL path + the
124
+ * scope), so it's cosmetic but honest.
125
+ *
126
+ * The scope is signed VERBATIM — the operator minting on their own box (holding
127
+ * `parachute:host:auth`, gated upstream in the command) IS the authority, the
128
+ * same way `auth mint-token` mints a scope directly.
129
+ */
130
+ export async function mintSurfaceToken(
131
+ db: Database,
132
+ opts: MintSurfaceTokenOpts,
133
+ ): Promise<MintedSurfaceToken> {
134
+ if (!SURFACE_NAME_RE.test(opts.name)) {
135
+ throw new Error(
136
+ `invalid surface name "${opts.name}" — must match ${SURFACE_NAME_RE} (kebab/alnum, no slashes or dots)`,
137
+ );
138
+ }
139
+ const scope = surfaceScope(opts.name, opts.access);
140
+ const ttlSeconds = opts.ttlSeconds ?? SURFACE_TOKEN_TTL_DEFAULT_SECONDS;
141
+ const sign = opts.signToken ?? signAccessToken;
142
+ const signed = await sign(db, {
143
+ sub: SURFACE_TOKEN_SUBJECT,
144
+ scopes: [scope],
145
+ audience: `surface.${opts.name}`,
146
+ clientId: SURFACE_TOKEN_CLIENT_ID,
147
+ issuer: opts.issuer,
148
+ ttlSeconds,
149
+ vaultScope: [], // not a per-user vault credential
150
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
151
+ });
152
+ // Register the long-lived mint so revoke can drop it (registered-mint rule —
153
+ // an unregistered long-lived token is unrevocable).
154
+ recordTokenMint(db, {
155
+ jti: signed.jti,
156
+ createdVia: SURFACE_TOKEN_CREATED_VIA,
157
+ subject: SURFACE_TOKEN_SUBJECT,
158
+ ...(opts.userId ? { userId: opts.userId } : {}),
159
+ clientId: SURFACE_TOKEN_CLIENT_ID,
160
+ scopes: [scope],
161
+ expiresAt: signed.expiresAt,
162
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
163
+ });
164
+ return { token: signed.token, jti: signed.jti, expiresAt: signed.expiresAt, scope };
165
+ }
166
+
167
+ /** A deploy token's listing row (metadata only — never the token bytes). */
168
+ export interface SurfaceTokenListing {
169
+ jti: string;
170
+ /** Surface name parsed from the scope. */
171
+ name: string;
172
+ access: SurfaceAccess;
173
+ scope: string;
174
+ createdAt: string;
175
+ expiresAt: string;
176
+ /** ISO timestamp when revoked, or null if live. */
177
+ revokedAt: string | null;
178
+ }
179
+
180
+ /**
181
+ * List surface deploy tokens, newest-first, optionally narrowed to one surface.
182
+ * Pages through the whole `surface_token` class (the registry filter) and keeps
183
+ * only rows whose scope is a well-formed `surface:<name>:<verb>` — a deploy token
184
+ * always carries exactly that one scope. Metadata only; the token bytes are never
185
+ * stored, so they never appear here.
186
+ */
187
+ export function listSurfaceTokens(db: Database, name?: string): SurfaceTokenListing[] {
188
+ const out: SurfaceTokenListing[] = [];
189
+ let cursor: string | null = null;
190
+ do {
191
+ const page = listTokens(db, {
192
+ filter: { createdVia: SURFACE_TOKEN_CREATED_VIA },
193
+ cursor,
194
+ });
195
+ for (const row of page.rows) {
196
+ const scope = row.scopes.find((s) => SURFACE_SCOPE_RE.test(s));
197
+ if (!scope) continue;
198
+ const m = SURFACE_SCOPE_RE.exec(scope);
199
+ if (!m) continue;
200
+ const parsedName = m[1] ?? "";
201
+ const access = (m[2] ?? "read") as SurfaceAccess;
202
+ if (name !== undefined && parsedName !== name) continue;
203
+ out.push({
204
+ jti: row.jti,
205
+ name: parsedName,
206
+ access,
207
+ scope,
208
+ createdAt: row.createdAt,
209
+ expiresAt: row.expiresAt,
210
+ revokedAt: row.revokedAt,
211
+ });
212
+ }
213
+ cursor = page.nextCursor;
214
+ } while (cursor);
215
+ return out;
216
+ }
217
+
218
+ /** Outcome of a revoke attempt. */
219
+ export type RevokeSurfaceTokenResult =
220
+ | { status: "revoked"; jti: string }
221
+ | { status: "already-revoked"; jti: string; revokedAt: string }
222
+ | { status: "not-found"; jti: string }
223
+ | { status: "not-surface-token"; jti: string; createdVia: string };
224
+
225
+ /**
226
+ * Revoke a surface deploy token by jti. Fails closed on a jti that is NOT a
227
+ * deploy token (`not-surface-token`) so this command can't be turned into a
228
+ * general token-revoker — the operator uses `auth revoke-token` for other kinds.
229
+ * Idempotent: re-revoking an already-revoked deploy token reports its existing
230
+ * `revokedAt` (the caller exits 0).
231
+ */
232
+ export function revokeSurfaceToken(db: Database, jti: string, now: Date): RevokeSurfaceTokenResult {
233
+ const row = findTokenRowByJti(db, jti);
234
+ if (!row) return { status: "not-found", jti };
235
+ if (row.createdVia !== SURFACE_TOKEN_CREATED_VIA) {
236
+ return { status: "not-surface-token", jti, createdVia: row.createdVia };
237
+ }
238
+ if (row.revokedAt) return { status: "already-revoked", jti, revokedAt: row.revokedAt };
239
+ const ok = revokeTokenByJti(db, jti, now);
240
+ // Race: the row existed then vanished/was-revoked between our lookups. Report
241
+ // not-found rather than silently succeeding (mirrors `auth revoke-token`).
242
+ if (!ok) return { status: "not-found", jti };
243
+ return { status: "revoked", jti };
244
+ }