@openparachute/hub 0.5.0 → 0.5.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -6,11 +6,13 @@ import { registerClient } from "../clients.ts";
6
6
  import { type AuthDeps, type Runner, auth, authHelp } from "../commands/auth.ts";
7
7
  import { findGrant, recordGrant } from "../grants.ts";
8
8
  import { hubDbPath, openHubDb } from "../hub-db.ts";
9
- import { validateAccessToken } from "../jwt-sign.ts";
9
+ import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
10
10
  import {
11
11
  OPERATOR_TOKEN_AUDIENCE,
12
+ OPERATOR_TOKEN_CLIENT_ID,
12
13
  OPERATOR_TOKEN_SCOPES,
13
14
  readOperatorTokenFile,
15
+ writeOperatorTokenFile,
14
16
  } from "../operator-token.ts";
15
17
  import { createUser, listUsers, verifyPassword } from "../users.ts";
16
18
 
@@ -794,3 +796,352 @@ describe("parachute auth list-grants / revoke-grant", () => {
794
796
  }
795
797
  });
796
798
  });
799
+
800
+ // closes #179 — scope-narrow JWT minting against operator identity, for
801
+ // agent-secret injection and other on-box callers that want a tight bearer.
802
+ describe("parachute auth mint-token", () => {
803
+ test("missing --scope is a usage error", async () => {
804
+ const tmp = makeTmp();
805
+ try {
806
+ const { code, stderr } = await captureOutput(() =>
807
+ auth(["mint-token"], { dbPath: tmp.dbPath, configDir: tmp.dir }),
808
+ );
809
+ expect(code).toBe(1);
810
+ expect(stderr).toContain("--scope is required");
811
+ } finally {
812
+ tmp.cleanup();
813
+ }
814
+ });
815
+
816
+ test("no operator.token on disk is an actionable error", async () => {
817
+ const tmp = makeTmp();
818
+ try {
819
+ const { code, stderr } = await captureOutput(() =>
820
+ auth(["mint-token", "--scope", "scribe:transcribe"], {
821
+ dbPath: tmp.dbPath,
822
+ configDir: tmp.dir,
823
+ }),
824
+ );
825
+ expect(code).toBe(1);
826
+ expect(stderr).toContain("operator.token");
827
+ expect(stderr).toContain("rotate-operator");
828
+ } finally {
829
+ tmp.cleanup();
830
+ }
831
+ });
832
+
833
+ test("scope-only mint emits a JWT signed by the active key, audience inferred", async () => {
834
+ const tmp = makeTmp();
835
+ try {
836
+ const deps: AuthDeps = {
837
+ dbPath: tmp.dbPath,
838
+ configDir: tmp.dir,
839
+ isInteractive: () => false,
840
+ };
841
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
842
+ const { code, stdout } = await captureOutput(() =>
843
+ auth(["mint-token", "--scope", "scribe:transcribe"], deps),
844
+ );
845
+ expect(code).toBe(0);
846
+ const token = stdout.trim();
847
+ expect(token.split(".").length).toBe(3);
848
+ // Strict purity: stdout is exactly the token + trailing newline,
849
+ // nothing extra. Pipes (`| pbcopy`, `| jq`) depend on this.
850
+ expect(stdout).toBe(`${token}\n`);
851
+ const db = openHubDb(tmp.dbPath);
852
+ try {
853
+ const validated = await validateAccessToken(db, token);
854
+ expect(validated.payload.aud).toBe("scribe");
855
+ expect(validated.payload.scope).toBe("scribe:transcribe");
856
+ const users = listUsers(db);
857
+ expect(validated.payload.sub).toBe(users[0]?.id);
858
+ } finally {
859
+ db.close();
860
+ }
861
+ } finally {
862
+ tmp.cleanup();
863
+ }
864
+ });
865
+
866
+ test("operator token without hub:admin scope is rejected (no token emitted)", async () => {
867
+ const tmp = makeTmp();
868
+ try {
869
+ const deps: AuthDeps = {
870
+ dbPath: tmp.dbPath,
871
+ configDir: tmp.dir,
872
+ isInteractive: () => false,
873
+ };
874
+ // Bootstrap: set-password to seed the user + signing key.
875
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
876
+ // Now overwrite operator.token with a valid-signature, valid-expiry
877
+ // JWT that lacks hub:admin — simulating someone stashing a narrow
878
+ // token at the operator path.
879
+ const db = openHubDb(tmp.dbPath);
880
+ let narrow: string;
881
+ try {
882
+ const owner = listUsers(db)[0]!;
883
+ const signed = await signAccessToken(db, {
884
+ sub: owner.id,
885
+ scopes: ["scribe:transcribe"],
886
+ audience: "scribe",
887
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
888
+ issuer: "http://127.0.0.1:1939",
889
+ ttlSeconds: 3600,
890
+ });
891
+ narrow = signed.token;
892
+ } finally {
893
+ db.close();
894
+ }
895
+ await writeOperatorTokenFile(narrow, tmp.dir);
896
+
897
+ const { code, stdout, stderr } = await captureOutput(() =>
898
+ auth(["mint-token", "--scope", "scribe:transcribe"], deps),
899
+ );
900
+ expect(code).toBe(1);
901
+ expect(stderr).toContain("lacks hub:admin scope");
902
+ expect(stderr).toContain("rotate-operator");
903
+ // Purity: no token written to stdout.
904
+ expect(stdout).toBe("");
905
+ } finally {
906
+ tmp.cleanup();
907
+ }
908
+ });
909
+
910
+ test("named vault scope infers aud=vault.<name>", async () => {
911
+ const tmp = makeTmp();
912
+ try {
913
+ const deps: AuthDeps = {
914
+ dbPath: tmp.dbPath,
915
+ configDir: tmp.dir,
916
+ isInteractive: () => false,
917
+ };
918
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
919
+ const { code, stdout } = await captureOutput(() =>
920
+ auth(["mint-token", "--scope", "vault:work:read"], deps),
921
+ );
922
+ expect(code).toBe(0);
923
+ const token = stdout.trim();
924
+ const db = openHubDb(tmp.dbPath);
925
+ try {
926
+ const validated = await validateAccessToken(db, token);
927
+ expect(validated.payload.aud).toBe("vault.work");
928
+ } finally {
929
+ db.close();
930
+ }
931
+ } finally {
932
+ tmp.cleanup();
933
+ }
934
+ });
935
+
936
+ test("--aud override beats inference", async () => {
937
+ const tmp = makeTmp();
938
+ try {
939
+ const deps: AuthDeps = {
940
+ dbPath: tmp.dbPath,
941
+ configDir: tmp.dir,
942
+ isInteractive: () => false,
943
+ };
944
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
945
+ const { code, stdout } = await captureOutput(() =>
946
+ auth(["mint-token", "--scope", "vault:work:read", "--aud", "custom-resource"], deps),
947
+ );
948
+ expect(code).toBe(0);
949
+ const token = stdout.trim();
950
+ const db = openHubDb(tmp.dbPath);
951
+ try {
952
+ const validated = await validateAccessToken(db, token);
953
+ expect(validated.payload.aud).toBe("custom-resource");
954
+ } finally {
955
+ db.close();
956
+ }
957
+ } finally {
958
+ tmp.cleanup();
959
+ }
960
+ });
961
+
962
+ test("--ttl honored; expiry math matches", async () => {
963
+ const tmp = makeTmp();
964
+ try {
965
+ const deps: AuthDeps = {
966
+ dbPath: tmp.dbPath,
967
+ configDir: tmp.dir,
968
+ isInteractive: () => false,
969
+ };
970
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
971
+ const { code, stdout } = await captureOutput(() =>
972
+ auth(["mint-token", "--scope", "scribe:transcribe", "--ttl", "1h"], deps),
973
+ );
974
+ expect(code).toBe(0);
975
+ const token = stdout.trim();
976
+ const db = openHubDb(tmp.dbPath);
977
+ try {
978
+ const validated = await validateAccessToken(db, token);
979
+ const exp = validated.payload.exp;
980
+ const iat = validated.payload.iat;
981
+ if (typeof exp !== "number" || typeof iat !== "number") {
982
+ throw new Error("expected numeric exp+iat");
983
+ }
984
+ expect(exp - iat).toBe(3600);
985
+ } finally {
986
+ db.close();
987
+ }
988
+ } finally {
989
+ tmp.cleanup();
990
+ }
991
+ });
992
+
993
+ test("--ttl=365d is accepted (boundary)", async () => {
994
+ const tmp = makeTmp();
995
+ try {
996
+ const deps: AuthDeps = {
997
+ dbPath: tmp.dbPath,
998
+ configDir: tmp.dir,
999
+ isInteractive: () => false,
1000
+ };
1001
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1002
+ const { code, stdout } = await captureOutput(() =>
1003
+ auth(["mint-token", "--scope", "scribe:transcribe", "--ttl", "365d"], deps),
1004
+ );
1005
+ expect(code).toBe(0);
1006
+ const token = stdout.trim();
1007
+ const db = openHubDb(tmp.dbPath);
1008
+ try {
1009
+ const validated = await validateAccessToken(db, token);
1010
+ const exp = validated.payload.exp;
1011
+ const iat = validated.payload.iat;
1012
+ if (typeof exp !== "number" || typeof iat !== "number") {
1013
+ throw new Error("expected numeric exp+iat");
1014
+ }
1015
+ expect(exp - iat).toBe(365 * 24 * 60 * 60);
1016
+ } finally {
1017
+ db.close();
1018
+ }
1019
+ } finally {
1020
+ tmp.cleanup();
1021
+ }
1022
+ });
1023
+
1024
+ test("--ttl=0s is rejected (must be > 0)", async () => {
1025
+ const tmp = makeTmp();
1026
+ try {
1027
+ const deps: AuthDeps = {
1028
+ dbPath: tmp.dbPath,
1029
+ configDir: tmp.dir,
1030
+ isInteractive: () => false,
1031
+ };
1032
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1033
+ const { code, stderr } = await captureOutput(() =>
1034
+ auth(["mint-token", "--scope", "scribe:transcribe", "--ttl", "0s"], deps),
1035
+ );
1036
+ expect(code).toBe(1);
1037
+ expect(stderr).toContain("must be > 0");
1038
+ } finally {
1039
+ tmp.cleanup();
1040
+ }
1041
+ });
1042
+
1043
+ test("--ttl > 365d errors", async () => {
1044
+ const tmp = makeTmp();
1045
+ try {
1046
+ const deps: AuthDeps = {
1047
+ dbPath: tmp.dbPath,
1048
+ configDir: tmp.dir,
1049
+ isInteractive: () => false,
1050
+ };
1051
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1052
+ const { code, stderr } = await captureOutput(() =>
1053
+ auth(["mint-token", "--scope", "scribe:transcribe", "--ttl", "400d"], deps),
1054
+ );
1055
+ expect(code).toBe(1);
1056
+ expect(stderr).toContain("365d cap");
1057
+ } finally {
1058
+ tmp.cleanup();
1059
+ }
1060
+ });
1061
+
1062
+ test("--ttl with invalid format errors", async () => {
1063
+ const tmp = makeTmp();
1064
+ try {
1065
+ const deps: AuthDeps = {
1066
+ dbPath: tmp.dbPath,
1067
+ configDir: tmp.dir,
1068
+ isInteractive: () => false,
1069
+ };
1070
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1071
+ const { code, stderr } = await captureOutput(() =>
1072
+ auth(["mint-token", "--scope", "scribe:transcribe", "--ttl", "1week"], deps),
1073
+ );
1074
+ expect(code).toBe(1);
1075
+ expect(stderr).toContain("invalid --ttl");
1076
+ } finally {
1077
+ tmp.cleanup();
1078
+ }
1079
+ });
1080
+
1081
+ test("multiple scopes (space-separated) carried verbatim into the JWT", async () => {
1082
+ const tmp = makeTmp();
1083
+ try {
1084
+ const deps: AuthDeps = {
1085
+ dbPath: tmp.dbPath,
1086
+ configDir: tmp.dir,
1087
+ isInteractive: () => false,
1088
+ };
1089
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1090
+ const { code, stdout } = await captureOutput(() =>
1091
+ auth(["mint-token", "--scope", "vault:work:read scribe:transcribe"], deps),
1092
+ );
1093
+ expect(code).toBe(0);
1094
+ const token = stdout.trim();
1095
+ const db = openHubDb(tmp.dbPath);
1096
+ try {
1097
+ const validated = await validateAccessToken(db, token);
1098
+ expect(validated.payload.scope).toBe("vault:work:read scribe:transcribe");
1099
+ // Named vault scope wins for audience inference.
1100
+ expect(validated.payload.aud).toBe("vault.work");
1101
+ } finally {
1102
+ db.close();
1103
+ }
1104
+ } finally {
1105
+ tmp.cleanup();
1106
+ }
1107
+ });
1108
+
1109
+ test("--sub override emits the JWT with that subject", async () => {
1110
+ const tmp = makeTmp();
1111
+ try {
1112
+ const deps: AuthDeps = {
1113
+ dbPath: tmp.dbPath,
1114
+ configDir: tmp.dir,
1115
+ isInteractive: () => false,
1116
+ };
1117
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
1118
+ const { code, stdout } = await captureOutput(() =>
1119
+ auth(["mint-token", "--scope", "scribe:transcribe", "--sub", "agent:scribe-runner"], deps),
1120
+ );
1121
+ expect(code).toBe(0);
1122
+ const token = stdout.trim();
1123
+ const db = openHubDb(tmp.dbPath);
1124
+ try {
1125
+ const validated = await validateAccessToken(db, token);
1126
+ expect(validated.payload.sub).toBe("agent:scribe-runner");
1127
+ } finally {
1128
+ db.close();
1129
+ }
1130
+ } finally {
1131
+ tmp.cleanup();
1132
+ }
1133
+ });
1134
+
1135
+ test("unknown flag errors", async () => {
1136
+ const tmp = makeTmp();
1137
+ try {
1138
+ const { code, stderr } = await captureOutput(() =>
1139
+ auth(["mint-token", "--lol"], { dbPath: tmp.dbPath, configDir: tmp.dir }),
1140
+ );
1141
+ expect(code).toBe(1);
1142
+ expect(stderr).toContain("unknown flag");
1143
+ } finally {
1144
+ tmp.cleanup();
1145
+ }
1146
+ });
1147
+ });
@@ -517,9 +517,13 @@ describe("parachute start", () => {
517
517
  }
518
518
  });
519
519
 
520
- test("third-party with no installDir errors as unknown service", async () => {
521
- // A row whose name isn't a known short name AND has no installDir is
522
- // unmanageable we have no way to find a spec for it.
520
+ test("start: installDir-less third-party row surfaces an actionable error", async () => {
521
+ // A services.json row whose name isn't first-party AND has no installDir
522
+ // can't yield a startCmd. Pre-fix this hit the generic "unknown service"
523
+ // path (misleading — the row exists, just with stale shape). Post-fix
524
+ // resolveTargets returns the entry with spec=undefined and start prints
525
+ // an actionable message that points at the real fix (re-install or
526
+ // upgrade-the-module).
523
527
  const h = makeHarness();
524
528
  try {
525
529
  upsertService(
@@ -539,7 +543,30 @@ describe("parachute start", () => {
539
543
  log: (l) => lines.push(l),
540
544
  });
541
545
  expect(code).toBe(1);
542
- expect(lines.join("\n")).toMatch(/unknown service "mystery"/);
546
+ const out = lines.join("\n");
547
+ expect(out).toMatch(/services\.json entry has no installDir/);
548
+ expect(out).toMatch(/parachute install <path-to-mystery>/);
549
+ expect(out).not.toMatch(/unknown service/);
550
+ } finally {
551
+ h.cleanup();
552
+ }
553
+ });
554
+
555
+ test("start: name absent from services.json still errors as unknown service", async () => {
556
+ // The genuinely-unknown path: no first-party fallback, no row in
557
+ // services.json. Distinguish from the above (row exists but lacks
558
+ // installDir) so the error message is right-shaped for each.
559
+ const h = makeHarness();
560
+ try {
561
+ seedVault(h.manifestPath);
562
+ const lines: string[] = [];
563
+ const code = await start("ghost", {
564
+ configDir: h.configDir,
565
+ manifestPath: h.manifestPath,
566
+ log: (l) => lines.push(l),
567
+ });
568
+ expect(code).toBe(1);
569
+ expect(lines.join("\n")).toMatch(/unknown service "ghost"/);
543
570
  } finally {
544
571
  h.cleanup();
545
572
  }
@@ -721,6 +748,45 @@ describe("parachute stop", () => {
721
748
  h.cleanup();
722
749
  }
723
750
  });
751
+
752
+ test("third-party row without installDir: stops via pidfile", async () => {
753
+ // Graceful-degradation path: an installed-but-stale third-party row
754
+ // (no installDir field — pre-installDir-contract self-registration)
755
+ // should still be stoppable. stop only needs the short name to find
756
+ // the pidfile; spec resolution isn't on the critical path for stop.
757
+ const h = makeHarness();
758
+ try {
759
+ upsertService(
760
+ {
761
+ name: "mystery",
762
+ port: 1944,
763
+ paths: ["/mystery"],
764
+ health: "/mystery/health",
765
+ version: "0.0.1",
766
+ },
767
+ h.manifestPath,
768
+ );
769
+ writePid("mystery", 4242, h.configDir);
770
+ const killed: Array<[number, string | number]> = [];
771
+ let aliveCall = 0;
772
+ const code = await stop("mystery", {
773
+ configDir: h.configDir,
774
+ manifestPath: h.manifestPath,
775
+ kill: (pid, sig) => killed.push([pid, sig]),
776
+ alive: () => {
777
+ aliveCall++;
778
+ return aliveCall === 1;
779
+ },
780
+ sleep: async () => {},
781
+ log: () => {},
782
+ });
783
+ expect(code).toBe(0);
784
+ expect(killed).toEqual([[4242, "SIGTERM"]]);
785
+ expect(readPid("mystery", h.configDir)).toBeUndefined();
786
+ } finally {
787
+ h.cleanup();
788
+ }
789
+ });
724
790
  });
725
791
 
726
792
  describe("parachute restart", () => {
@@ -822,6 +888,37 @@ describe("parachute logs", () => {
822
888
  h.cleanup();
823
889
  }
824
890
  });
891
+
892
+ test("third-party row without installDir: tails by short name", async () => {
893
+ // Graceful-degradation path: log file is keyed by short name, written by
894
+ // start. installDir is irrelevant for tailing — the entry just needs to
895
+ // exist in services.json.
896
+ const h = makeHarness();
897
+ try {
898
+ upsertService(
899
+ {
900
+ name: "mystery",
901
+ port: 1944,
902
+ paths: ["/mystery"],
903
+ health: "/mystery/health",
904
+ version: "0.0.1",
905
+ },
906
+ h.manifestPath,
907
+ );
908
+ const p = ensureLogPath("mystery", h.configDir);
909
+ writeFileSync(p, "mystery line 1\nmystery line 2\n");
910
+ const lines: string[] = [];
911
+ const code = await logs("mystery", {
912
+ configDir: h.configDir,
913
+ manifestPath: h.manifestPath,
914
+ log: (l) => lines.push(l),
915
+ });
916
+ expect(code).toBe(0);
917
+ expect(lines).toEqual(["mystery line 1", "mystery line 2"]);
918
+ } finally {
919
+ h.cleanup();
920
+ }
921
+ });
825
922
  });
826
923
 
827
924
  describe("process-group lifecycle (hub#88)", () => {
@@ -25,7 +25,13 @@ import { listGrantsForUser, revokeGrant } from "../grants.ts";
25
25
  import { HUB_DEFAULT_PORT, readHubPort } from "../hub-control.ts";
26
26
  import { openHubDb } from "../hub-db.ts";
27
27
  import { deriveHubOrigin } from "../hub-origin.ts";
28
- import { issueOperatorToken } from "../operator-token.ts";
28
+ import { inferAudience } from "../jwt-audience.ts";
29
+ import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
30
+ import {
31
+ OPERATOR_TOKEN_CLIENT_ID,
32
+ issueOperatorToken,
33
+ readOperatorTokenFile,
34
+ } from "../operator-token.ts";
29
35
  import { rotateSigningKey } from "../signing-keys.ts";
30
36
  import {
31
37
  SingleUserModeError,
@@ -54,6 +60,7 @@ const HUB_LOCAL_SUBCOMMANDS = new Set([
54
60
  "set-password",
55
61
  "list-users",
56
62
  "rotate-operator",
63
+ "mint-token",
57
64
  "pending-clients",
58
65
  "approve-client",
59
66
  "list-grants",
@@ -73,6 +80,9 @@ Usage:
73
80
  parachute auth 2fa backup-codes Regenerate backup codes
74
81
  parachute auth rotate-key Rotate the hub's JWT signing key
75
82
  parachute auth rotate-operator Mint a fresh ~/.parachute/operator.token
83
+ parachute auth mint-token --scope <scope> [--aud <aud>] [--ttl <duration>] [--sub <sub>]
84
+ Mint a scope-narrow JWT against the
85
+ operator's identity (stdout = JWT)
76
86
  parachute auth pending-clients List OAuth clients awaiting approval
77
87
  parachute auth approve-client <id> Approve a pending OAuth client
78
88
  parachute auth list-grants [--username <name>]
@@ -104,6 +114,15 @@ rotate-operator mints a fresh long-lived operator token at
104
114
  as their bearer when calling on-box services. set-password also writes
105
115
  the file on first-run / password reset.
106
116
 
117
+ mint-token issues a single scope-narrow JWT against the operator's
118
+ identity, signed with the same key as OAuth-issued tokens. Pipeable:
119
+ \`parachute auth mint-token --scope scribe:transcribe | pbcopy\`. The
120
+ audience defaults via the same inference rule the OAuth flow uses
121
+ (named \`vault:<name>:<verb>\` → \`vault.<name>\`, otherwise the first
122
+ colon-prefixed scope's namespace, fallback \`hub\`). TTL defaults to 90d,
123
+ caps at 365d. Requires a valid ~/.parachute/operator.token (run
124
+ \`parachute auth set-password\` or \`rotate-operator\` first).
125
+
107
126
  pending-clients + approve-client gate /oauth/register against operator
108
127
  approval (closes #74). Self-served DCR registrations land as 'pending'
109
128
  and cannot OAuth until you run \`parachute auth approve-client <id>\`.
@@ -571,6 +590,177 @@ function runRevokeGrant(args: readonly string[], deps: AuthDeps): number {
571
590
  }
572
591
  }
573
592
 
593
+ interface MintTokenFlags {
594
+ scope?: string;
595
+ aud?: string;
596
+ ttl?: string;
597
+ sub?: string;
598
+ error?: string;
599
+ }
600
+
601
+ function parseMintTokenFlags(args: readonly string[]): MintTokenFlags {
602
+ let scope: string | undefined;
603
+ let aud: string | undefined;
604
+ let ttl: string | undefined;
605
+ let sub: string | undefined;
606
+ for (let i = 0; i < args.length; i++) {
607
+ const a = args[i];
608
+ if (a === "--scope") {
609
+ const v = args[++i];
610
+ if (!v) return { error: "--scope requires a value" };
611
+ scope = v;
612
+ } else if (a?.startsWith("--scope=")) {
613
+ scope = a.slice("--scope=".length);
614
+ if (!scope) return { error: "--scope requires a value" };
615
+ } else if (a === "--aud") {
616
+ const v = args[++i];
617
+ if (!v) return { error: "--aud requires a value" };
618
+ aud = v;
619
+ } else if (a?.startsWith("--aud=")) {
620
+ aud = a.slice("--aud=".length);
621
+ if (!aud) return { error: "--aud requires a value" };
622
+ } else if (a === "--ttl") {
623
+ const v = args[++i];
624
+ if (!v) return { error: "--ttl requires a value" };
625
+ ttl = v;
626
+ } else if (a?.startsWith("--ttl=")) {
627
+ ttl = a.slice("--ttl=".length);
628
+ if (!ttl) return { error: "--ttl requires a value" };
629
+ } else if (a === "--sub") {
630
+ const v = args[++i];
631
+ if (!v) return { error: "--sub requires a value" };
632
+ sub = v;
633
+ } else if (a?.startsWith("--sub=")) {
634
+ sub = a.slice("--sub=".length);
635
+ if (!sub) return { error: "--sub requires a value" };
636
+ } else {
637
+ return { error: `unknown flag "${a}"` };
638
+ }
639
+ }
640
+ return { scope, aud, ttl, sub };
641
+ }
642
+
643
+ const MINT_TOKEN_TTL_DEFAULT_SECONDS = 90 * 24 * 60 * 60;
644
+ const MINT_TOKEN_TTL_MAX_SECONDS = 365 * 24 * 60 * 60;
645
+
646
+ /**
647
+ * Parse a Go-ish duration string: integer + one of d/h/m/s. Caps at 365d.
648
+ * `90d` → 7776000. We don't honor Go's stdlib `time.ParseDuration` exactly
649
+ * (no `d` there), so this is a small custom parser to keep the operator
650
+ * surface obvious.
651
+ */
652
+ function parseTtl(input: string): { seconds: number } | { error: string } {
653
+ const m = /^(\d+)(d|h|m|s)$/.exec(input);
654
+ if (!m) return { error: `invalid --ttl "${input}" — expected e.g. 90d, 24h, 30m, 60s` };
655
+ const n = Number.parseInt(m[1]!, 10);
656
+ if (!Number.isFinite(n) || n <= 0) return { error: `invalid --ttl "${input}" — must be > 0` };
657
+ const unit = m[2]!;
658
+ const mult = unit === "d" ? 86400 : unit === "h" ? 3600 : unit === "m" ? 60 : 1;
659
+ const seconds = n * mult;
660
+ if (seconds > MINT_TOKEN_TTL_MAX_SECONDS) {
661
+ return { error: `--ttl "${input}" exceeds 365d cap` };
662
+ }
663
+ return { seconds };
664
+ }
665
+
666
+ async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<number> {
667
+ const flags = parseMintTokenFlags(args);
668
+ if (flags.error) {
669
+ console.error(`parachute auth mint-token: ${flags.error}`);
670
+ return 1;
671
+ }
672
+ if (!flags.scope) {
673
+ console.error("parachute auth mint-token: --scope is required");
674
+ console.error(
675
+ "usage: parachute auth mint-token --scope <scope> [--aud <aud>] [--ttl <duration>] [--sub <sub>]",
676
+ );
677
+ return 1;
678
+ }
679
+
680
+ const scopes = flags.scope.split(/\s+/).filter((s) => s.length > 0);
681
+ if (scopes.length === 0) {
682
+ console.error("parachute auth mint-token: --scope must contain at least one scope");
683
+ return 1;
684
+ }
685
+
686
+ let ttlSeconds = MINT_TOKEN_TTL_DEFAULT_SECONDS;
687
+ if (flags.ttl) {
688
+ const parsed = parseTtl(flags.ttl);
689
+ if ("error" in parsed) {
690
+ console.error(`parachute auth mint-token: ${parsed.error}`);
691
+ return 1;
692
+ }
693
+ ttlSeconds = parsed.seconds;
694
+ }
695
+
696
+ const configDir = deps.configDir ?? CONFIG_DIR;
697
+ const operatorToken = await readOperatorTokenFile(configDir);
698
+ if (!operatorToken) {
699
+ console.error(
700
+ "parachute auth mint-token: no operator token found at ~/.parachute/operator.token",
701
+ );
702
+ console.error(
703
+ "run `parachute auth set-password` (first run) or `parachute auth rotate-operator` to mint one",
704
+ );
705
+ return 1;
706
+ }
707
+
708
+ const issuer = resolveHubIssuer(deps.hubOrigin, configDir);
709
+
710
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
711
+ try {
712
+ let operatorSub: string;
713
+ try {
714
+ const validated = await validateAccessToken(db, operatorToken, issuer);
715
+ const sub = validated.payload.sub;
716
+ if (typeof sub !== "string" || sub.length === 0) {
717
+ console.error("parachute auth mint-token: operator token has no sub claim");
718
+ return 1;
719
+ }
720
+ // Scope gate: a valid signature + non-expired JWT at this path is not
721
+ // sufficient — the token must carry operator-equivalent scope. Without
722
+ // this, a narrowly-scoped JWT stashed at ~/.parachute/operator.token
723
+ // would be treated as operator-bearer and mint arbitrary tokens
724
+ // (privilege escalation: narrow → arbitrary). Only set-password and
725
+ // rotate-operator legitimately write to this path; both seed the full
726
+ // OPERATOR_TOKEN_SCOPES set, so hub:admin is the right gate.
727
+ const tokenScope =
728
+ typeof validated.payload.scope === "string"
729
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
730
+ : [];
731
+ if (!tokenScope.includes("hub:admin")) {
732
+ console.error("parachute auth mint-token: operator token lacks hub:admin scope");
733
+ console.error("run `parachute auth rotate-operator` to mint a fresh one");
734
+ return 1;
735
+ }
736
+ operatorSub = sub;
737
+ } catch (err) {
738
+ const msg = err instanceof Error ? err.message : String(err);
739
+ console.error(`parachute auth mint-token: operator token invalid — ${msg}`);
740
+ console.error(
741
+ "run `parachute auth rotate-operator` to mint a fresh one, or check that the hub origin matches",
742
+ );
743
+ return 1;
744
+ }
745
+
746
+ const audience = flags.aud ?? inferAudience(scopes);
747
+ const sub = flags.sub ?? operatorSub;
748
+
749
+ const minted = await signAccessToken(db, {
750
+ sub,
751
+ scopes,
752
+ audience,
753
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
754
+ issuer,
755
+ ttlSeconds,
756
+ });
757
+ console.log(minted.token);
758
+ return 0;
759
+ } finally {
760
+ db.close();
761
+ }
762
+ }
763
+
574
764
  function runListUsers(deps: AuthDeps): number {
575
765
  const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
576
766
  try {
@@ -641,6 +831,15 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
641
831
  return 1;
642
832
  }
643
833
  }
834
+ if (sub === "mint-token") {
835
+ try {
836
+ return await runMintToken(args.slice(1), normalized);
837
+ } catch (err) {
838
+ const msg = err instanceof Error ? err.message : String(err);
839
+ console.error(`parachute auth mint-token: ${msg}`);
840
+ return 1;
841
+ }
842
+ }
644
843
  if (sub === "pending-clients") {
645
844
  try {
646
845
  return runPendingClients(normalized);
@@ -214,8 +214,10 @@ interface ResolvedTarget {
214
214
  * Lifecycle spec resolved at request time. First-party comes from
215
215
  * `getSpec(short)`; third-party comes from
216
216
  * `getSpecFromInstallDir(entry.installDir, ...)`. May be undefined when
217
- * a row has neither — lifecycle prints "lifecycle not yet supported"
218
- * for that service rather than crashing the whole sweep.
217
+ * a row has neither — `start` prints the actionable "no installDir"
218
+ * re-install message for an installDir-less third-party row, or
219
+ * "lifecycle not yet supported" otherwise; `stop`/`logs` keep working
220
+ * via pidfile/logfile semantics keyed by `short`.
219
221
  */
220
222
  spec: ServiceSpec | undefined;
221
223
  }
@@ -248,6 +250,13 @@ async function specForEntry(
248
250
  * `module.json` (which is what install copied to `entry.name` for
249
251
  * third-party). First-party are addressed by their short name (vault,
250
252
  * notes, …) and matched via `shortNameForManifest`.
253
+ *
254
+ * Named-path detail: a third-party row whose name matches but lacks
255
+ * `installDir` resolves to the entry with `spec: undefined` (rather than
256
+ * an "unknown service" error). `stop`/`logs` handle the spec-less case
257
+ * via pidfile/logfile semantics; `start` surfaces an actionable
258
+ * re-install hint downstream. The genuinely-unknown path (no first-party
259
+ * fallback AND no row in services.json) still errors as `unknown service`.
251
260
  */
252
261
  async function resolveTargets(
253
262
  svc: string | undefined,
@@ -268,13 +277,19 @@ async function resolveTargets(
268
277
  }
269
278
  return { targets: [{ short: svc, entry, spec: firstPartySpec }] };
270
279
  }
271
- // Third-party: match a services.json row by name. Third-party rows
272
- // carry `installDir`; without it we have no way to resolve a spec.
280
+ // Third-party: match a services.json row by name. Rows with `installDir`
281
+ // resolve a full spec from the on-disk module.json. Rows without it are
282
+ // still managed (stop/logs use pidfile/logfile semantics keyed by short
283
+ // name), but with `spec: undefined` — `start` will surface an
284
+ // installDir-specific error downstream rather than reject up front.
273
285
  const entry = manifest.services.find((s) => s.name === svc);
274
- if (entry?.installDir) {
275
- const { spec, error } = await specForEntry(svc, entry);
276
- if (error) return { error: `${svc}: invalid module.json ${error}` };
277
- return { targets: [{ short: svc, entry, spec }] };
286
+ if (entry) {
287
+ if (entry.installDir) {
288
+ const { spec, error } = await specForEntry(svc, entry);
289
+ if (error) return { error: `${svc}: invalid module.json ${error}` };
290
+ return { targets: [{ short: svc, entry, spec }] };
291
+ }
292
+ return { targets: [{ short: svc, entry, spec: undefined }] };
278
293
  }
279
294
  return {
280
295
  error: `unknown service "${svc}". known: ${knownServices().join(", ")}`,
@@ -323,7 +338,17 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
323
338
 
324
339
  const cmd = spec?.startCmd?.(entry);
325
340
  if (!cmd || cmd.length === 0) {
326
- r.log(`${short}: lifecycle not yet supported for this service.`);
341
+ // Distinguish the missing-installDir case from "spec resolved but has
342
+ // no startCmd" — the former is fixable by re-registering the module,
343
+ // the latter is a hub-level limitation. Third-party rows hit the first
344
+ // branch when their self-registration predates the installDir contract.
345
+ if (!getSpec(short) && !entry.installDir) {
346
+ r.log(
347
+ `${short}: services.json entry has no installDir, so the start command can't be resolved. Re-run \`parachute install <path-to-${short}>\` to refresh its registration, or upgrade the module to a version that self-registers with installDir.`,
348
+ );
349
+ } else {
350
+ r.log(`${short}: lifecycle not yet supported for this service.`);
351
+ }
327
352
  failures++;
328
353
  continue;
329
354
  }
@@ -487,13 +512,14 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
487
512
 
488
513
  // logs only needs a valid short name to find the log file. First-party
489
514
  // wins via the spec lookup; third-party rows match by `entry.name`; the
490
- // internal hub is a known short outside of services.json. We don't need
491
- // the full spec here — we just need to confirm the name maps to
492
- // something the CLI manages.
515
+ // internal hub is a known short outside of services.json. installDir is
516
+ // irrelevant here — the log file is keyed by short name and exists once
517
+ // the service has run, regardless of how it was registered. We just need
518
+ // to confirm the name maps to something the CLI manages.
493
519
  const isFirstParty = getSpec(svc) !== undefined;
494
520
  if (!isFirstParty && svc !== HUB_SVC) {
495
521
  const entry = readManifest(manifestPath).services.find((s) => s.name === svc);
496
- if (!entry?.installDir) {
522
+ if (!entry) {
497
523
  log(`unknown service "${svc}". known: ${[HUB_SVC, ...knownServices()].join(", ")}`);
498
524
  return 1;
499
525
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Audience derivation for hub-issued JWTs. Used by both:
3
+ * - `/oauth/token` (auth_code redemption + refresh rotation)
4
+ * - `parachute auth mint-token` (CLI shortcut for scope-narrow tokens)
5
+ *
6
+ * Per the vault-config-and-scopes design (Phase 1+2):
7
+ * - A named `vault:<name>:<verb>` → `vault.<name>` (RFC 8707-style resource
8
+ * binding; vault enforces this strict-equality against the URL-derived
9
+ * vault name).
10
+ * - An unnamed `<service>:<verb>` → `<service>` (legacy shape; vault's
11
+ * strict-check rejects unnamed `vault:*` audiences, so the consent
12
+ * picker rewrites those before this is reached).
13
+ * - Fallback: `hub` (no namespaced scope).
14
+ *
15
+ * Named vault scopes win over unnamed ones — an OAuth flow that mixes
16
+ * `vault:work:read` + `scribe:transcribe` audiences is grounded on the vault
17
+ * (the more sensitive resource), and tokens are issued per-flow anyway.
18
+ *
19
+ * Hoisted from `oauth-handlers.ts` so CLI mints and OAuth mints can't diverge
20
+ * on audience semantics — a divergence here means tokens minted via CLI fail
21
+ * audience strict-check at the resource server even though scopes match.
22
+ */
23
+
24
+ export const VAULT_VERBS = new Set(["read", "write", "admin"]);
25
+
26
+ export function inferAudience(scopes: readonly string[]): string {
27
+ for (const s of scopes) {
28
+ const parts = s.split(":");
29
+ const name = parts[1];
30
+ const verb = parts[2];
31
+ if (parts.length === 3 && parts[0] === "vault" && name && verb && VAULT_VERBS.has(verb)) {
32
+ return `vault.${name}`;
33
+ }
34
+ }
35
+ for (const s of scopes) {
36
+ const colon = s.indexOf(":");
37
+ if (colon > 0) return s.slice(0, colon);
38
+ }
39
+ return "hub";
40
+ }
@@ -43,6 +43,7 @@ import {
43
43
  } from "./clients.ts";
44
44
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
45
45
  import { isCoveredByGrant, recordGrant } from "./grants.ts";
46
+ import { VAULT_VERBS, inferAudience } from "./jwt-audience.ts";
46
47
  import {
47
48
  ACCESS_TOKEN_TTL_SECONDS,
48
49
  RefreshTokenInsertError,
@@ -69,8 +70,6 @@ import {
69
70
  import { getUserByUsername, verifyPassword } from "./users.ts";
70
71
  import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
71
72
 
72
- const VAULT_VERBS = new Set(["read", "write", "admin"]);
73
-
74
73
  /** Verbs whose unnamed `vault:<verb>` form needs picker disambiguation. */
75
74
  function unnamedVaultVerbs(scopes: string[]): string[] {
76
75
  const verbs: string[] = [];
@@ -1056,36 +1055,6 @@ function mapAuthCodeError(err: unknown): Response {
1056
1055
  return jsonResponse({ error: "server_error", error_description: msg }, 500);
1057
1056
  }
1058
1057
 
1059
- /**
1060
- * Picks the JWT `aud` claim based on the requested scopes. Per the
1061
- * vault-config-and-scopes design (Phase 1+2):
1062
- * - A named `vault:<name>:<verb>` → `vault.<name>` (RFC 8707-style resource
1063
- * binding; vault enforces this strict-equality against the URL-derived
1064
- * vault name).
1065
- * - An unnamed `<service>:<verb>` → `<service>` (legacy shape; vault's
1066
- * strict-check rejects unnamed `vault:*` audiences, so the consent
1067
- * picker rewrites those before this is reached).
1068
- *
1069
- * Named vault scopes win over unnamed ones — an OAuth flow that mixes
1070
- * `vault:work:read` + `scribe:transcribe` audiences is grounded on the vault
1071
- * (the more sensitive resource), and tokens are issued per-flow anyway.
1072
- */
1073
- function inferAudience(scopes: string[]): string {
1074
- for (const s of scopes) {
1075
- const parts = s.split(":");
1076
- const name = parts[1];
1077
- const verb = parts[2];
1078
- if (parts.length === 3 && parts[0] === "vault" && name && verb && VAULT_VERBS.has(verb)) {
1079
- return `vault.${name}`;
1080
- }
1081
- }
1082
- for (const s of scopes) {
1083
- const colon = s.indexOf(":");
1084
- if (colon > 0) return s.slice(0, colon);
1085
- }
1086
- return "hub";
1087
- }
1088
-
1089
1058
  // --- /oauth/register -------------------------------------------------------
1090
1059
 
1091
1060
  interface RegisterRequestBody {