@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,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. */