@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,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parachute surface` — operator management for the Surface Git Transport.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3a ships the DEPLOY TOKEN sub-surface: `parachute surface token
|
|
5
|
+
* mint|list|revoke`. A deploy token is the PAT-equivalent — a scoped, revocable,
|
|
6
|
+
* long-lived `surface:<name>:<read|write>` credential the operator mints on the
|
|
7
|
+
* box and hands to an EXTERNAL/remote git client (a `claude -p` agent, or any
|
|
8
|
+
* machine) so it can `git push`/`git clone` a surface's hub-hosted repo with
|
|
9
|
+
* nothing but a static secret. No browser, no device flow (those are the human
|
|
10
|
+
* paths, later phases) — "a GitHub PAT, but for a parachute surface, git-native."
|
|
11
|
+
*
|
|
12
|
+
* The token mechanics live in the pure `surface-token.ts` library (mint / list /
|
|
13
|
+
* revoke against an open hub DB, reusing the same `signAccessToken` +
|
|
14
|
+
* registered-mint discipline as agent grants). This command layer owns:
|
|
15
|
+
* - operator-auth (the on-disk `operator.token` must carry `parachute:host:auth`,
|
|
16
|
+
* mirroring `auth mint-token` / `auth revoke-token` — minting/revoking a
|
|
17
|
+
* credential is privileged); and
|
|
18
|
+
* - argument parsing + the "just works" copy-paste git config guidance.
|
|
19
|
+
*
|
|
20
|
+
* Operator-managed governance: the operator minting on their own box IS the
|
|
21
|
+
* authority (the same way `auth mint-token` mints a scope directly). List + revoke
|
|
22
|
+
* give GitHub-PAT-style management — kill a leaked token.
|
|
23
|
+
*/
|
|
24
|
+
import { CONFIG_DIR } from "../config.ts";
|
|
25
|
+
import { surfaceGitRemoteUrl } from "../git-registry.ts";
|
|
26
|
+
import { surfaceHelp } from "../help.ts";
|
|
27
|
+
import { openHubDb } from "../hub-db.ts";
|
|
28
|
+
import { resolveHubIssuer } from "../hub-issuer.ts";
|
|
29
|
+
import { OperatorTokenExpiredError, useOperatorTokenWithAutoRotate } from "../operator-token.ts";
|
|
30
|
+
import {
|
|
31
|
+
SURFACE_TOKEN_TTL_DEFAULT_SECONDS,
|
|
32
|
+
SURFACE_TOKEN_TTL_MAX_SECONDS,
|
|
33
|
+
type SurfaceAccess,
|
|
34
|
+
listSurfaceTokens,
|
|
35
|
+
mintSurfaceToken,
|
|
36
|
+
revokeSurfaceToken,
|
|
37
|
+
} from "../surface-token.ts";
|
|
38
|
+
|
|
39
|
+
/** Injectable deps for tests — otherwise defaults to the real hub DB + config dir. */
|
|
40
|
+
export interface SurfaceDeps {
|
|
41
|
+
/** Override the hub-db path. Tests point at a tmp dir. */
|
|
42
|
+
dbPath?: string;
|
|
43
|
+
/** Override the config dir where `operator.token` / `expose-state.json` live. */
|
|
44
|
+
configDir?: string;
|
|
45
|
+
/** Override the hub origin used as the minted token's `iss`. */
|
|
46
|
+
hubOrigin?: string;
|
|
47
|
+
/** Test seam for the clock. */
|
|
48
|
+
now?: () => Date;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Entry point for `parachute surface …`. Only the `token` sub-surface exists in
|
|
53
|
+
* Phase 3a; unknown subcommands fall through to help with exit 1.
|
|
54
|
+
*/
|
|
55
|
+
export async function surface(args: readonly string[], deps: SurfaceDeps = {}): Promise<number> {
|
|
56
|
+
const sub = args[0];
|
|
57
|
+
if (sub === undefined || sub === "--help" || sub === "-h" || sub === "help") {
|
|
58
|
+
console.log(surfaceHelp());
|
|
59
|
+
return sub === undefined ? 1 : 0;
|
|
60
|
+
}
|
|
61
|
+
if (sub === "token") {
|
|
62
|
+
return await runToken(args.slice(1), deps);
|
|
63
|
+
}
|
|
64
|
+
console.error(`parachute surface: unknown subcommand "${sub}"`);
|
|
65
|
+
console.error("run `parachute surface --help` for usage");
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function runToken(args: readonly string[], deps: SurfaceDeps): Promise<number> {
|
|
70
|
+
const verb = args[0];
|
|
71
|
+
if (verb === undefined || verb === "--help" || verb === "-h" || verb === "help") {
|
|
72
|
+
console.log(surfaceHelp());
|
|
73
|
+
return verb === undefined ? 1 : 0;
|
|
74
|
+
}
|
|
75
|
+
switch (verb) {
|
|
76
|
+
case "mint":
|
|
77
|
+
return await runTokenMint(args.slice(1), deps);
|
|
78
|
+
case "list":
|
|
79
|
+
return await runTokenList(args.slice(1), deps);
|
|
80
|
+
case "revoke":
|
|
81
|
+
return await runTokenRevoke(args.slice(1), deps);
|
|
82
|
+
default:
|
|
83
|
+
console.error(`parachute surface token: unknown action "${verb}"`);
|
|
84
|
+
console.error("usage: parachute surface token <mint|list|revoke> …");
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ===========================================================================
|
|
90
|
+
// Operator-auth gate — the on-disk operator.token must carry parachute:host:auth
|
|
91
|
+
// ===========================================================================
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Load + validate the on-disk operator token and require `parachute:host:auth`
|
|
95
|
+
* (the `auth` or `admin` scope-set). Mirrors the gate in `auth mint-token` /
|
|
96
|
+
* `auth revoke-token` — minting / revoking a credential is privileged, and a
|
|
97
|
+
* narrowly-scoped JWT stashed at operator.token must not be able to do it. On
|
|
98
|
+
* success returns the operator's `userId` (registry attribution) + the resolved
|
|
99
|
+
* issuer; on any failure prints an actionable error and returns the exit code.
|
|
100
|
+
*/
|
|
101
|
+
async function requireOperatorHostAuth(
|
|
102
|
+
db: ReturnType<typeof openHubDb>,
|
|
103
|
+
deps: SurfaceDeps,
|
|
104
|
+
label: string,
|
|
105
|
+
): Promise<{ userId: string; issuer: string } | number> {
|
|
106
|
+
const configDir = deps.configDir ?? CONFIG_DIR;
|
|
107
|
+
const issuer = resolveHubIssuer(deps.hubOrigin, configDir);
|
|
108
|
+
|
|
109
|
+
let used: Awaited<ReturnType<typeof useOperatorTokenWithAutoRotate>>;
|
|
110
|
+
try {
|
|
111
|
+
used = await useOperatorTokenWithAutoRotate(db, { configDir, issuer });
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err instanceof OperatorTokenExpiredError) {
|
|
114
|
+
console.error(`${label}: ${err.message}`);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
118
|
+
console.error(`${label}: operator token invalid — ${msg}`);
|
|
119
|
+
console.error(
|
|
120
|
+
"run `parachute auth rotate-operator` to mint a fresh one, or check that the hub origin matches",
|
|
121
|
+
);
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
if (!used) {
|
|
125
|
+
console.error(`${label}: no operator token found at ~/.parachute/operator.token`);
|
|
126
|
+
console.error(
|
|
127
|
+
"run `parachute auth set-password` (first run) or `parachute auth rotate-operator` to mint one",
|
|
128
|
+
);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
if (used.rotated) {
|
|
132
|
+
console.error(
|
|
133
|
+
`${label}: operator token within 7d of expiry — auto-rotated to ${used.rotated.expiresAt} (scope_set=${used.rotated.scopeSet})`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const operatorSub = used.payload.sub;
|
|
137
|
+
if (typeof operatorSub !== "string" || operatorSub.length === 0) {
|
|
138
|
+
console.error(`${label}: operator token has no sub claim`);
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
const tokenScope =
|
|
142
|
+
typeof used.payload.scope === "string"
|
|
143
|
+
? used.payload.scope.split(/\s+/).filter((s) => s.length > 0)
|
|
144
|
+
: [];
|
|
145
|
+
if (!tokenScope.includes("parachute:host:auth")) {
|
|
146
|
+
console.error(`${label}: operator token lacks parachute:host:auth scope`);
|
|
147
|
+
console.error(
|
|
148
|
+
"narrowed scope-sets without `auth` (install/start/expose/vault) can't manage deploy tokens — run `parachute auth rotate-operator --scope-set auth` (or `admin`)",
|
|
149
|
+
);
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
return { userId: operatorSub, issuer };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ===========================================================================
|
|
156
|
+
// mint
|
|
157
|
+
// ===========================================================================
|
|
158
|
+
|
|
159
|
+
interface MintFlags {
|
|
160
|
+
name?: string;
|
|
161
|
+
access: SurfaceAccess;
|
|
162
|
+
ttlSeconds: number;
|
|
163
|
+
json: boolean;
|
|
164
|
+
error?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse the mint args: a single surface-name positional + `--read|--write`
|
|
169
|
+
* (default write — a deploy token's job is to push) + `--ttl <dur>` /
|
|
170
|
+
* `--expires-in <s>` (default 90d, cap 365d) + `--json`.
|
|
171
|
+
*/
|
|
172
|
+
function parseMintFlags(args: readonly string[]): MintFlags {
|
|
173
|
+
let name: string | undefined;
|
|
174
|
+
let access: SurfaceAccess | undefined;
|
|
175
|
+
let ttlSeconds = SURFACE_TOKEN_TTL_DEFAULT_SECONDS;
|
|
176
|
+
let json = false;
|
|
177
|
+
|
|
178
|
+
for (let i = 0; i < args.length; i++) {
|
|
179
|
+
const a = args[i]!;
|
|
180
|
+
if (a === "--read") {
|
|
181
|
+
if (access === "write")
|
|
182
|
+
return {
|
|
183
|
+
access: "write",
|
|
184
|
+
ttlSeconds,
|
|
185
|
+
json,
|
|
186
|
+
error: "--read and --write are mutually exclusive",
|
|
187
|
+
};
|
|
188
|
+
access = "read";
|
|
189
|
+
} else if (a === "--write") {
|
|
190
|
+
if (access === "read")
|
|
191
|
+
return {
|
|
192
|
+
access: "read",
|
|
193
|
+
ttlSeconds,
|
|
194
|
+
json,
|
|
195
|
+
error: "--read and --write are mutually exclusive",
|
|
196
|
+
};
|
|
197
|
+
access = "write";
|
|
198
|
+
} else if (a === "--json") {
|
|
199
|
+
json = true;
|
|
200
|
+
} else if (a === "--ttl" || a === "--expires-in") {
|
|
201
|
+
const val = args[++i];
|
|
202
|
+
if (val === undefined)
|
|
203
|
+
return { access: access ?? "write", ttlSeconds, json, error: `${a} requires a value` };
|
|
204
|
+
const parsed = a === "--ttl" ? parseDuration(val) : parseSeconds(val);
|
|
205
|
+
if ("error" in parsed)
|
|
206
|
+
return { access: access ?? "write", ttlSeconds, json, error: parsed.error };
|
|
207
|
+
ttlSeconds = parsed.seconds;
|
|
208
|
+
} else if (a.startsWith("--")) {
|
|
209
|
+
return { access: access ?? "write", ttlSeconds, json, error: `unknown flag "${a}"` };
|
|
210
|
+
} else if (name === undefined) {
|
|
211
|
+
name = a;
|
|
212
|
+
} else {
|
|
213
|
+
return {
|
|
214
|
+
access: access ?? "write",
|
|
215
|
+
ttlSeconds,
|
|
216
|
+
json,
|
|
217
|
+
error: `unexpected argument "${a}" (only one surface name)`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { name, access: access ?? "write", ttlSeconds, json };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function runTokenMint(args: readonly string[], deps: SurfaceDeps): Promise<number> {
|
|
225
|
+
const label = "parachute surface token mint";
|
|
226
|
+
const flags = parseMintFlags(args);
|
|
227
|
+
if (flags.error) {
|
|
228
|
+
console.error(`${label}: ${flags.error}`);
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
if (!flags.name) {
|
|
232
|
+
console.error(`${label}: missing surface name`);
|
|
233
|
+
console.error(
|
|
234
|
+
"usage: parachute surface token mint <name> [--read|--write] [--ttl <dur>] [--json]",
|
|
235
|
+
);
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
240
|
+
try {
|
|
241
|
+
const auth = await requireOperatorHostAuth(db, deps, label);
|
|
242
|
+
if (typeof auth === "number") return auth;
|
|
243
|
+
|
|
244
|
+
let minted: Awaited<ReturnType<typeof mintSurfaceToken>>;
|
|
245
|
+
try {
|
|
246
|
+
minted = await mintSurfaceToken(db, {
|
|
247
|
+
name: flags.name,
|
|
248
|
+
access: flags.access,
|
|
249
|
+
issuer: auth.issuer,
|
|
250
|
+
ttlSeconds: flags.ttlSeconds,
|
|
251
|
+
userId: auth.userId,
|
|
252
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
253
|
+
});
|
|
254
|
+
} catch (err) {
|
|
255
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
256
|
+
console.error(`${label}: ${msg}`);
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const remoteUrl = surfaceGitRemoteUrl(auth.issuer, flags.name);
|
|
261
|
+
const loopback = /^https?:\/\/(127\.0\.0\.1|\[::1\]|localhost)(:|\/|$)/.test(auth.issuer);
|
|
262
|
+
|
|
263
|
+
if (flags.json) {
|
|
264
|
+
// Machine-consumable: everything a remote client needs to configure git,
|
|
265
|
+
// in one blob (easy to hand a `claude -p` agent as config).
|
|
266
|
+
console.log(
|
|
267
|
+
JSON.stringify(
|
|
268
|
+
{
|
|
269
|
+
token: minted.token,
|
|
270
|
+
jti: minted.jti,
|
|
271
|
+
scope: minted.scope,
|
|
272
|
+
surface: flags.name,
|
|
273
|
+
access: flags.access,
|
|
274
|
+
expiresAt: minted.expiresAt,
|
|
275
|
+
remoteUrl,
|
|
276
|
+
credentialHelper: CREDENTIAL_HELPER_INLINE,
|
|
277
|
+
},
|
|
278
|
+
null,
|
|
279
|
+
2,
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Human path: the token is the ONLY thing on stdout (pipe purity — e.g.
|
|
286
|
+
// `parachute surface token mint x --write | pbcopy`); everything else is
|
|
287
|
+
// guidance on stderr.
|
|
288
|
+
console.log(minted.token);
|
|
289
|
+
console.error("");
|
|
290
|
+
console.error(`Surface deploy token minted for "${flags.name}" (${flags.access}).`);
|
|
291
|
+
console.error(` jti: ${minted.jti}`);
|
|
292
|
+
console.error(` scope: ${minted.scope}`);
|
|
293
|
+
console.error(` expires: ${minted.expiresAt}`);
|
|
294
|
+
console.error(` remote: ${remoteUrl}`);
|
|
295
|
+
console.error(` revoke: parachute surface token revoke ${minted.jti}`);
|
|
296
|
+
console.error("");
|
|
297
|
+
console.error("The token was printed to stdout. Hand it to the remote client as a secret,");
|
|
298
|
+
console.error("then, on that machine (any box with git — NO parachute install needed):");
|
|
299
|
+
console.error("");
|
|
300
|
+
console.error(" export PARACHUTE_SURFACE_TOKEN=<paste-the-token>");
|
|
301
|
+
console.error(` git config --global credential.helper '${CREDENTIAL_HELPER_INLINE}'`);
|
|
302
|
+
console.error(` git clone ${remoteUrl} my-surface # then edit + git push`);
|
|
303
|
+
console.error("");
|
|
304
|
+
console.error("Keep the token in an env var / credential helper — never commit it or put it");
|
|
305
|
+
console.error("in a remote URL (it would leak into .git/config).");
|
|
306
|
+
if (loopback) {
|
|
307
|
+
console.error("");
|
|
308
|
+
console.error(
|
|
309
|
+
`NOTE: the hub origin is loopback (${auth.issuer}) — a remote client can only reach`,
|
|
310
|
+
);
|
|
311
|
+
console.error(
|
|
312
|
+
"this surface once the box is exposed (`parachute expose …`). Re-mint after exposing so",
|
|
313
|
+
);
|
|
314
|
+
console.error("the remote URL points at the public origin.");
|
|
315
|
+
}
|
|
316
|
+
return 0;
|
|
317
|
+
} finally {
|
|
318
|
+
db.close();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ===========================================================================
|
|
323
|
+
// list
|
|
324
|
+
// ===========================================================================
|
|
325
|
+
|
|
326
|
+
async function runTokenList(args: readonly string[], deps: SurfaceDeps): Promise<number> {
|
|
327
|
+
const label = "parachute surface token list";
|
|
328
|
+
let name: string | undefined;
|
|
329
|
+
let json = false;
|
|
330
|
+
for (const a of args) {
|
|
331
|
+
if (a === "--json") json = true;
|
|
332
|
+
else if (a.startsWith("--")) {
|
|
333
|
+
console.error(`${label}: unknown flag "${a}"`);
|
|
334
|
+
return 1;
|
|
335
|
+
} else if (name === undefined) name = a;
|
|
336
|
+
else {
|
|
337
|
+
console.error(`${label}: unexpected argument "${a}" (only one surface name)`);
|
|
338
|
+
return 1;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
343
|
+
try {
|
|
344
|
+
const auth = await requireOperatorHostAuth(db, deps, label);
|
|
345
|
+
if (typeof auth === "number") return auth;
|
|
346
|
+
|
|
347
|
+
const now = deps.now?.() ?? new Date();
|
|
348
|
+
const rows = listSurfaceTokens(db, name).map((r) => ({
|
|
349
|
+
...r,
|
|
350
|
+
status: statusOf(r.revokedAt, r.expiresAt, now),
|
|
351
|
+
}));
|
|
352
|
+
|
|
353
|
+
if (json) {
|
|
354
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
if (rows.length === 0) {
|
|
358
|
+
console.log(
|
|
359
|
+
name
|
|
360
|
+
? `No surface deploy tokens for "${name}".`
|
|
361
|
+
: "No surface deploy tokens. Mint one with `parachute surface token mint <name>`.",
|
|
362
|
+
);
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
|
365
|
+
// Fixed-ish columns; jti + surface names are bounded, so a simple pad reads
|
|
366
|
+
// cleanly without a table lib.
|
|
367
|
+
console.log(
|
|
368
|
+
`${pad("JTI", 24)} ${pad("SURFACE", 20)} ${pad("ACCESS", 6)} ${pad("STATUS", 8)} EXPIRES`,
|
|
369
|
+
);
|
|
370
|
+
for (const r of rows) {
|
|
371
|
+
console.log(
|
|
372
|
+
`${pad(r.jti, 24)} ${pad(r.name, 20)} ${pad(r.access, 6)} ${pad(r.status, 8)} ${r.expiresAt}`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return 0;
|
|
376
|
+
} finally {
|
|
377
|
+
db.close();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ===========================================================================
|
|
382
|
+
// revoke
|
|
383
|
+
// ===========================================================================
|
|
384
|
+
|
|
385
|
+
async function runTokenRevoke(args: readonly string[], deps: SurfaceDeps): Promise<number> {
|
|
386
|
+
const label = "parachute surface token revoke";
|
|
387
|
+
const positionals = args.filter((a) => !a.startsWith("--"));
|
|
388
|
+
const flags = args.filter((a) => a.startsWith("--"));
|
|
389
|
+
if (flags.length > 0) {
|
|
390
|
+
console.error(
|
|
391
|
+
`${label}: unexpected flag "${flags[0]}" (this command takes a jti positional only)`,
|
|
392
|
+
);
|
|
393
|
+
return 1;
|
|
394
|
+
}
|
|
395
|
+
if (positionals.length === 0) {
|
|
396
|
+
console.error(`${label}: missing jti argument`);
|
|
397
|
+
console.error(
|
|
398
|
+
"usage: parachute surface token revoke <jti> (find it with `parachute surface token list`)",
|
|
399
|
+
);
|
|
400
|
+
return 1;
|
|
401
|
+
}
|
|
402
|
+
if (positionals.length > 1) {
|
|
403
|
+
console.error(`${label}: unexpected argument "${positionals[1]}" (only one jti at a time)`);
|
|
404
|
+
return 1;
|
|
405
|
+
}
|
|
406
|
+
const jti = positionals[0]!;
|
|
407
|
+
|
|
408
|
+
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
409
|
+
try {
|
|
410
|
+
const auth = await requireOperatorHostAuth(db, deps, label);
|
|
411
|
+
if (typeof auth === "number") return auth;
|
|
412
|
+
|
|
413
|
+
const result = revokeSurfaceToken(db, jti, deps.now?.() ?? new Date());
|
|
414
|
+
switch (result.status) {
|
|
415
|
+
case "revoked":
|
|
416
|
+
console.log(
|
|
417
|
+
`Revoked surface deploy token ${jti}. Effective immediately — the git endpoint rejects it on the next push.`,
|
|
418
|
+
);
|
|
419
|
+
return 0;
|
|
420
|
+
case "already-revoked":
|
|
421
|
+
console.log(`already revoked at ${result.revokedAt}: jti=${jti}`);
|
|
422
|
+
return 0;
|
|
423
|
+
case "not-found":
|
|
424
|
+
console.error(`${label}: no surface deploy token with jti ${jti} in the registry`);
|
|
425
|
+
return 1;
|
|
426
|
+
case "not-surface-token":
|
|
427
|
+
console.error(
|
|
428
|
+
`${label}: jti ${jti} is not a surface deploy token (it is a ${result.createdVia} token)`,
|
|
429
|
+
);
|
|
430
|
+
console.error("use `parachute auth revoke-token` to revoke non-surface tokens");
|
|
431
|
+
return 1;
|
|
432
|
+
}
|
|
433
|
+
} finally {
|
|
434
|
+
db.close();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ===========================================================================
|
|
439
|
+
// Shared helpers
|
|
440
|
+
// ===========================================================================
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The zero-file git credential helper (inline `!`-command form) — the "just
|
|
444
|
+
* works" mechanism. On `get`, emits `x-access-token:<token>` from
|
|
445
|
+
* `$PARACHUTE_SURFACE_TOKEN`, which the hub git endpoint accepts as Basic
|
|
446
|
+
* (`extractToken` in git-transport.ts). Works on ANY git version + any box with
|
|
447
|
+
* `sh` — no parachute install, no extra file. The named `git-credential-parachute`
|
|
448
|
+
* script (scripts/) is the equivalent for repeated use.
|
|
449
|
+
*/
|
|
450
|
+
const CREDENTIAL_HELPER_INLINE =
|
|
451
|
+
'!f() { test "$1" = get && printf "username=x-access-token\\npassword=%s\\n" "$PARACHUTE_SURFACE_TOKEN"; }; f';
|
|
452
|
+
|
|
453
|
+
function statusOf(revokedAt: string | null, expiresAt: string, now: Date): string {
|
|
454
|
+
if (revokedAt) return "revoked";
|
|
455
|
+
if (Date.parse(expiresAt) <= now.getTime()) return "expired";
|
|
456
|
+
return "active";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function pad(s: string, width: number): string {
|
|
460
|
+
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function parseSeconds(input: string): { seconds: number } | { error: string } {
|
|
464
|
+
const seconds = Number.parseInt(input, 10);
|
|
465
|
+
if (!Number.isFinite(seconds) || String(seconds) !== input.trim() || seconds <= 0) {
|
|
466
|
+
return {
|
|
467
|
+
error: `invalid --expires-in "${input}" — must be a positive integer number of seconds`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
if (seconds > SURFACE_TOKEN_TTL_MAX_SECONDS) {
|
|
471
|
+
return {
|
|
472
|
+
error: `--expires-in "${input}" exceeds the 365d cap (${SURFACE_TOKEN_TTL_MAX_SECONDS} seconds)`,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
return { seconds };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Parse a duration with a d/h/m/s suffix (e.g. `90d`, `24h`, `30m`, `60s`). */
|
|
479
|
+
function parseDuration(input: string): { seconds: number } | { error: string } {
|
|
480
|
+
const m = /^(\d+)([dhms])$/.exec(input.trim());
|
|
481
|
+
if (!m) {
|
|
482
|
+
return { error: `invalid --ttl "${input}" — use a duration like 90d, 24h, 30m, or 60s` };
|
|
483
|
+
}
|
|
484
|
+
const n = Number.parseInt(m[1]!, 10);
|
|
485
|
+
const unit = m[2]!;
|
|
486
|
+
const mult = unit === "d" ? 86400 : unit === "h" ? 3600 : unit === "m" ? 60 : 1;
|
|
487
|
+
const seconds = n * mult;
|
|
488
|
+
if (seconds <= 0) return { error: `invalid --ttl "${input}" — must be > 0` };
|
|
489
|
+
if (seconds > SURFACE_TOKEN_TTL_MAX_SECONDS) {
|
|
490
|
+
return { error: `--ttl "${input}" exceeds the 365d cap` };
|
|
491
|
+
}
|
|
492
|
+
return { seconds };
|
|
493
|
+
}
|
package/src/help.ts
CHANGED
|
@@ -29,6 +29,8 @@ Usage:
|
|
|
29
29
|
parachute migrate --to-supervised move a legacy detached install to the managed hub
|
|
30
30
|
parachute migrate [--dry-run] archive legacy files at ecosystem root
|
|
31
31
|
parachute auth <cmd> identity (set password, manage 2FA)
|
|
32
|
+
parachute surface token <cmd> mint/list/revoke surface deploy tokens
|
|
33
|
+
(a git-native PAT for pushing to a surface)
|
|
32
34
|
parachute hub set-origin <url> set the canonical public hub origin (OAuth issuer)
|
|
33
35
|
— for reverse-proxy / Caddy-direct boxes
|
|
34
36
|
parachute vault <args...> vault-specific ops (tokens, 2fa, config, init,
|
|
@@ -801,3 +803,65 @@ Examples:
|
|
|
801
803
|
parachute migrate --teardown remove the hub unit (roll back the cutover)
|
|
802
804
|
`;
|
|
803
805
|
}
|
|
806
|
+
|
|
807
|
+
export function surfaceHelp(): string {
|
|
808
|
+
return `parachute surface — manage Parachute surfaces (Surface Git Transport)
|
|
809
|
+
|
|
810
|
+
Usage:
|
|
811
|
+
parachute surface token mint <name> [--read|--write] [--ttl <dur> | --expires-in <s>] [--json]
|
|
812
|
+
parachute surface token list [<name>] [--json]
|
|
813
|
+
parachute surface token revoke <jti>
|
|
814
|
+
|
|
815
|
+
Deploy tokens — a GitHub-PAT-equivalent, git-native:
|
|
816
|
+
A surface lives as a git repo the hub authenticates (\`/git/<name>\`). A deploy
|
|
817
|
+
token lets an EXTERNAL/remote git client — a \`claude -p\` agent, or any machine
|
|
818
|
+
— push/pull that repo with nothing but a static secret. No browser, no device
|
|
819
|
+
flow. Mint one, hand it over, and \`git push\` just works.
|
|
820
|
+
|
|
821
|
+
The token is scoped to ONE surface + one verb (read xor write), registered, and
|
|
822
|
+
revocable — so you can list your deploy tokens and kill a leaked one, exactly
|
|
823
|
+
like managing GitHub PATs. Default lifetime is 90 days (re-mint to renew).
|
|
824
|
+
|
|
825
|
+
token mint <name> mint a deploy token for surface <name>.
|
|
826
|
+
--write push access — \`surface:<name>:write\` (DEFAULT; a
|
|
827
|
+
deploy token's job is to push). write also allows fetch.
|
|
828
|
+
--read clone/fetch only — \`surface:<name>:read\`.
|
|
829
|
+
--ttl <dur> lifetime as a duration (90d / 24h / 30m / 60s).
|
|
830
|
+
Default 90d; capped at 365d.
|
|
831
|
+
--expires-in <seconds> lifetime in integer seconds (alternative to --ttl).
|
|
832
|
+
--json emit a JSON blob (token + jti + scope + remoteUrl +
|
|
833
|
+
the git credential-helper one-liner) for scripted /
|
|
834
|
+
agent config. Otherwise the token is printed to stdout
|
|
835
|
+
(pipe-safe) and setup guidance to stderr.
|
|
836
|
+
|
|
837
|
+
token list [<name>] list deploy tokens (newest first), optionally narrowed
|
|
838
|
+
to one surface. Shows jti, surface, access, status
|
|
839
|
+
(active / revoked / expired), and expiry. --json for
|
|
840
|
+
machine output. Never prints the token bytes.
|
|
841
|
+
|
|
842
|
+
token revoke <jti> revoke a deploy token by jti (find it via \`token list\`).
|
|
843
|
+
Effective immediately — the git endpoint rejects it on
|
|
844
|
+
the next push (per-request revocation check). Idempotent.
|
|
845
|
+
Refuses non-deploy-token jtis — use
|
|
846
|
+
\`parachute auth revoke-token\` for those.
|
|
847
|
+
|
|
848
|
+
The remote-client setup (git-native, no \`gh\`, no parachute install needed):
|
|
849
|
+
|
|
850
|
+
# on the remote machine:
|
|
851
|
+
export PARACHUTE_SURFACE_TOKEN=<the-token>
|
|
852
|
+
git config --global credential.helper \\
|
|
853
|
+
'!f() { test "$1" = get && printf "username=x-access-token\\npassword=%s\\n" "$PARACHUTE_SURFACE_TOKEN"; }; f'
|
|
854
|
+
git clone https://<hub-origin>/git/<name> && cd <name> # edit, then:
|
|
855
|
+
git push
|
|
856
|
+
|
|
857
|
+
\`parachute surface token mint\` prints this with your hub origin filled in.
|
|
858
|
+
A reusable \`git-credential-parachute\` helper script ships in the hub repo's
|
|
859
|
+
scripts/ for boxes that prefer a named helper on PATH.
|
|
860
|
+
|
|
861
|
+
Auth:
|
|
862
|
+
mint / list / revoke require the on-disk operator token to carry
|
|
863
|
+
\`parachute:host:auth\` (the \`auth\` or \`admin\` scope-set) — the same gate as
|
|
864
|
+
\`parachute auth mint-token\`. Run \`parachute auth set-password\` (first run) or
|
|
865
|
+
\`parachute auth rotate-operator\` if you don't have one.
|
|
866
|
+
`;
|
|
867
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readExposeState } from "./expose-state.ts";
|
|
3
|
+
import { HUB_DEFAULT_PORT, readHubPort } from "./hub-control.ts";
|
|
4
|
+
import { deriveHubOrigin } from "./hub-origin.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the hub origin used as `iss` for operator-minted tokens (the CLI mint
|
|
8
|
+
* paths: `auth mint-token`, `auth revoke-token`, `surface token …`). Mirrors
|
|
9
|
+
* `lifecycle.resolveHubOrigin`'s order, but falls back to the canonical loopback
|
|
10
|
+
* (`http://127.0.0.1:1939`) instead of `undefined` — operator-minted tokens MUST
|
|
11
|
+
* carry an issuer, and on first-run before any expose has happened the canonical
|
|
12
|
+
* loopback is what services will validate against.
|
|
13
|
+
*
|
|
14
|
+
* Hoisted out of `commands/auth.ts` so every operator-mint surface resolves the
|
|
15
|
+
* issuer identically — a divergence here would mint tokens whose `iss` fails the
|
|
16
|
+
* resource server's strict check even when scopes match.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveHubIssuer(override: string | undefined, configDir: string): string {
|
|
19
|
+
if (override) {
|
|
20
|
+
const fromOverride = deriveHubOrigin({ override });
|
|
21
|
+
if (fromOverride) return fromOverride;
|
|
22
|
+
}
|
|
23
|
+
const state = readExposeState(join(configDir, "expose-state.json"));
|
|
24
|
+
if (state?.hubOrigin) return state.hubOrigin;
|
|
25
|
+
const exposeFqdn = state?.canonicalFqdn;
|
|
26
|
+
return (
|
|
27
|
+
deriveHubOrigin({ exposeFqdn, hubPort: readHubPort(configDir) }) ??
|
|
28
|
+
`http://127.0.0.1:${HUB_DEFAULT_PORT}`
|
|
29
|
+
);
|
|
30
|
+
}
|
package/src/jwt-sign.ts
CHANGED
|
@@ -179,7 +179,15 @@ export type TokenCreatedVia =
|
|
|
179
179
|
// Agent-connector grants (Phase 4b-1) — a vault token the hub mints when the
|
|
180
180
|
// operator approves an agent's `vault:<name>:<verb>` connection grant. Stored
|
|
181
181
|
// in the agent-grants store; registered here so revoke can drop it.
|
|
182
|
-
| "agent_grant"
|
|
182
|
+
| "agent_grant"
|
|
183
|
+
// Surface DEPLOY tokens (Surface Git Transport Phase 3a) — a long-lived,
|
|
184
|
+
// scoped, revocable `surface:<name>:<read|write>` token an operator mints for
|
|
185
|
+
// an EXTERNAL/remote git client (a `claude -p` agent or any box) to push/pull
|
|
186
|
+
// a surface's hub-hosted repo. The PAT-equivalent: distinct from `agent_grant`
|
|
187
|
+
// (in-framework, approval-gated, per-turn-injected) and `cli_mint` (generic),
|
|
188
|
+
// so `parachute surface token list` can show exactly these. Registered here so
|
|
189
|
+
// `surface token revoke` (and the revocation list) can drop it.
|
|
190
|
+
| "surface_token";
|
|
183
191
|
|
|
184
192
|
export interface SignedRefreshToken {
|
|
185
193
|
/** Opaque token to return to the client. NOT recoverable from the DB. */
|