@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.
- package/package.json +2 -1
- package/scripts/git-credential-parachute +50 -0
- package/src/__tests__/surface-command.test.ts +492 -0
- package/src/__tests__/surface-token.test.ts +276 -0
- package/src/cli.ts +6 -0
- package/src/commands/auth.ts +1 -25
- package/src/commands/surface.ts +493 -0
- package/src/help.ts +64 -0
- package/src/hub-issuer.ts +30 -0
- package/src/jwt-sign.ts +9 -1
- package/src/surface-token.ts +244 -0
|
@@ -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
|
+
}
|