@openparachute/hub 0.3.0-rc.1 → 0.5.0

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.
Files changed (90) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +712 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +519 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +652 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +242 -37
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-sign.ts +275 -0
  75. package/src/module-manifest.ts +435 -0
  76. package/src/oauth-handlers.ts +1206 -0
  77. package/src/oauth-ui.ts +582 -0
  78. package/src/operator-token.ts +129 -0
  79. package/src/providers/detect.ts +97 -0
  80. package/src/scope-explanations.ts +137 -0
  81. package/src/scope-registry.ts +158 -0
  82. package/src/service-spec.ts +270 -97
  83. package/src/services-manifest.ts +57 -1
  84. package/src/sessions.ts +115 -0
  85. package/src/signing-keys.ts +120 -0
  86. package/src/users.ts +144 -0
  87. package/src/well-known.ts +62 -26
  88. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  89. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  90. package/web/ui/dist/index.html +14 -0
@@ -13,10 +13,10 @@ describe("renderHub", () => {
13
13
  expect(html).toContain("<script>");
14
14
  });
15
15
 
16
- test("fetches /.well-known/parachute.json and iterates services[]", () => {
16
+ test("fetches /.well-known/parachute.json and reads services[] + vaults[]", () => {
17
17
  expect(html).toContain("/.well-known/parachute.json");
18
18
  expect(html).toContain("doc.services");
19
- expect(html).toContain("infoUrl");
19
+ expect(html).toContain("doc.vaults");
20
20
  });
21
21
 
22
22
  test("uses parachute.computer sage palette and serif/sans fonts", () => {
@@ -30,72 +30,79 @@ describe("renderHub", () => {
30
30
  expect(html).toContain("prefers-color-scheme: dark");
31
31
  });
32
32
 
33
- test("falls back to a generic icon when service has none", () => {
33
+ test("falls back to a generic icon for module tiles", () => {
34
34
  expect(html).toContain("fallbackIcon");
35
35
  });
36
36
 
37
- test("branches card rendering on info.kind (api/tool interactive, else link)", () => {
38
- // Script picks the element type and wires up toggling based on info.kind.
39
- expect(html).toContain("isInteractiveKind");
40
- expect(html).toContain("'api'");
41
- expect(html).toContain("'tool'");
42
- expect(html).toContain("'frontend'");
37
+ test("renders one tile per module type, not per service instance", () => {
38
+ expect(html).toContain("aggregate(services, vaults)");
39
+ expect(html).toContain("renderTile");
40
+ expect(html).toContain("MODULE_ORDER");
43
41
  });
44
42
 
45
- test("interactive cards get keyboard + aria affordances", () => {
46
- expect(html).toContain("role");
47
- expect(html).toContain("tabindex");
48
- expect(html).toContain("aria-expanded");
49
- expect(html).toContain("Enter");
43
+ test("known module display order is vault → scribe → notes → agent", () => {
44
+ expect(html).toContain("['vault', 'scribe', 'notes', 'agent']");
50
45
  });
51
46
 
52
- test("detail panel surfaces OAuth discovery, MCP, open-in-Notes, service URL", () => {
53
- expect(html).toContain("/.well-known/oauth-authorization-server");
54
- expect(html).toContain("info.mcpUrl");
55
- expect(html).toContain("info.openInNotesUrl");
56
- expect(html).toContain("Service URL");
57
- expect(html).toContain("OAuth discovery");
47
+ test("vault tile counts vaults[] (per instance) and links to /vault", () => {
48
+ // Vault count is the length of doc.vaults — one entry per /vault/<name>
49
+ // mount, so a single ServiceEntry with paths=[a,b,c] still shows "3
50
+ // registered". The manage link is the hub's vault SPA at /vault
51
+ // (renamed from /hub/vaults in the realignment), never an individual
52
+ // vault backend.
53
+ expect(html).toContain("vaults.length");
54
+ expect(html).toContain("'/vault'");
58
55
  });
59
56
 
60
- test("details panel is hidden until the card is expanded", () => {
61
- expect(html).toContain(".details {");
62
- expect(html).toContain("display: none");
63
- expect(html).toContain(".card.expanded .details");
57
+ test("non-vault tiles take their manageUrl from the service's path", () => {
58
+ // shortName('parachute-scribe') = 'scribe' → tile links to svc.path,
59
+ // which is whatever the module declared (e.g. /scribe, /notes, /agent).
60
+ // Hardcoding the link would silently break on a custom mount.
61
+ expect(html).toContain("manageUrl: svc.path");
64
62
  });
65
63
 
66
- test("lazy-fetches /.parachute/config/schema + /.parachute/config on first expand", () => {
67
- expect(html).toContain("fetchConfig");
68
- expect(html).toContain("/.parachute/config/schema");
69
- expect(html).toContain("/.parachute/config");
70
- // Lazy: fetch happens inside the toggle, guarded by configLoaded.
71
- expect(html).toContain("configLoaded");
64
+ test("tiles for module types with zero instances are hidden", () => {
65
+ // Aggregate only inserts a group when the type has at least one entry;
66
+ // tilesInOrder iterates the map. No "0 registered" surface.
67
+ expect(html).not.toContain("0 registered");
68
+ expect(html).toContain("count === 1 ? '1 registered'");
72
69
  });
73
70
 
74
- test("renders config form fields with disabled inputs (read-only in this launch)", () => {
75
- expect(html).toContain("renderConfigField");
76
- expect(html).toContain("input.disabled = true");
77
- expect(html).toContain("aria-readonly");
78
- // Hint text tells users where to edit instead.
79
- expect(html).toContain("read-only in this launch");
71
+ test("module labels are humanized (Vault / Scribe / Notes / Agent)", () => {
72
+ expect(html).toContain("vault: 'Vault'");
73
+ expect(html).toContain("scribe: 'Scribe'");
74
+ expect(html).toContain("notes: 'Notes'");
75
+ expect(html).toContain("agent: 'Agent'");
80
76
  });
81
77
 
82
- test("config field types: enum→select, boolean→checkbox, number→number, uri→url", () => {
83
- expect(html).toContain("schema.enum");
84
- expect(html).toContain("'checkbox'");
85
- expect(html).toContain("'number'");
86
- expect(html).toContain("'url'");
78
+ test("vault-name detection covers parachute-vault and parachute-vault-<name>", () => {
79
+ // Phase-1 multi-vault keeps a single ServiceEntry with multiple paths
80
+ // (parachute-vault), but the door is open for per-instance entries
81
+ // (parachute-vault-techne). isVaultName has to accept both.
82
+ expect(html).toContain("isVaultName");
83
+ expect(html).toContain("'parachute-vault'");
84
+ expect(html).toContain("'parachute-vault-'");
87
85
  });
88
86
 
89
- test("writeOnly fields render a bullet placeholder instead of the raw value", () => {
90
- expect(html).toContain("writeOnly");
91
- // Six bullets as the placeholder (template literal resolves \u2022 → •).
92
- expect(html).toContain("\u2022\u2022\u2022\u2022\u2022\u2022");
87
+ test("empty state when no modules are registered", () => {
88
+ expect(html).toContain("No modules installed yet");
89
+ expect(html).toContain("parachute install vault");
93
90
  });
94
91
 
95
- test("schema 404 path renders nothing (no error surfaced)", () => {
96
- // fetchConfig returns null on non-ok; caller skips render.
97
- expect(html).toContain("if (!schemaResp || !schemaResp.ok) return null");
98
- expect(html).toContain("if (data)");
92
+ test("error state surfaces the underlying message", () => {
93
+ expect(html).toContain("Could not load modules");
94
+ });
95
+
96
+ test("does not retain the per-service interactive-card / config-form code", () => {
97
+ // The home page is now a directory of modules — per-instance config
98
+ // forms and detail panels live behind the Manage links (vault SPA at
99
+ // /vault, the running module's own UI elsewhere). Keeping the
100
+ // dead code around is a maintenance trap.
101
+ expect(html).not.toContain("renderConfigField");
102
+ expect(html).not.toContain("fetchConfig");
103
+ expect(html).not.toContain("kind-badge");
104
+ expect(html).not.toContain("info.mcpUrl");
105
+ expect(html).not.toContain("info.openInNotesUrl");
99
106
  });
100
107
  });
101
108
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { install } from "../commands/install.ts";
@@ -15,7 +15,7 @@ function makeTempPath(): { path: string; configDir: string; cleanup: () => void
15
15
  }
16
16
 
17
17
  describe("install", () => {
18
- test("rejects unknown service with exit 1", async () => {
18
+ test("rejects third-party package with no module.json (hard error)", async () => {
19
19
  const { path, cleanup } = makeTempPath();
20
20
  try {
21
21
  const logs: string[] = [];
@@ -28,7 +28,7 @@ describe("install", () => {
28
28
  log: (l) => logs.push(l),
29
29
  });
30
30
  expect(code).toBe(1);
31
- expect(logs.join("\n")).toMatch(/unknown service/);
31
+ expect(logs.join("\n")).toMatch(/does not ship \.parachute\/module\.json/);
32
32
  } finally {
33
33
  cleanup();
34
34
  }
@@ -1142,4 +1142,328 @@ describe("install", () => {
1142
1142
  cleanup();
1143
1143
  }
1144
1144
  });
1145
+
1146
+ test("third-party npm package with valid module.json installs", async () => {
1147
+ const { path, cleanup } = makeTempPath();
1148
+ try {
1149
+ const calls: string[][] = [];
1150
+ const logs: string[] = [];
1151
+ const code = await install("@acme/widget", {
1152
+ runner: async (cmd) => {
1153
+ calls.push([...cmd]);
1154
+ return 0;
1155
+ },
1156
+ manifestPath: path,
1157
+ startService: async () => 0,
1158
+ isLinked: () => false,
1159
+ portProbe: async () => false,
1160
+ log: (l) => logs.push(l),
1161
+ readManifest: async () => ({
1162
+ name: "widget",
1163
+ manifestName: "@acme/widget",
1164
+ kind: "api",
1165
+ port: 1950,
1166
+ paths: ["/widget"],
1167
+ health: "/healthz",
1168
+ }),
1169
+ findGlobalInstall: () => "/fake/prefix/@acme/widget/package.json",
1170
+ });
1171
+ expect(code).toBe(0);
1172
+ expect(calls[0]).toEqual(["bun", "add", "-g", "@acme/widget"]);
1173
+ // hub#85: third-party rows are keyed by `manifest.name` (canonical
1174
+ // short — what `parachute start <svc>` accepts), not `manifestName`.
1175
+ const seeded = findService("widget", path);
1176
+ expect(seeded?.name).toBe("widget");
1177
+ expect(seeded?.port).toBe(1950);
1178
+ expect(findService("@acme/widget", path)).toBeUndefined();
1179
+ expect(logs.join("\n")).toMatch(/Seeded services\.json entry for widget/);
1180
+ expect(logs.join("\n")).toMatch(/widget registered on port 1950/);
1181
+ } finally {
1182
+ cleanup();
1183
+ }
1184
+ });
1185
+
1186
+ test("third-party npm package without module.json hard-errors", async () => {
1187
+ const { path, cleanup } = makeTempPath();
1188
+ try {
1189
+ const logs: string[] = [];
1190
+ const code = await install("@acme/widget", {
1191
+ runner: async () => 0,
1192
+ manifestPath: path,
1193
+ startService: async () => 0,
1194
+ isLinked: () => false,
1195
+ portProbe: async () => false,
1196
+ log: (l) => logs.push(l),
1197
+ readManifest: async () => null,
1198
+ findGlobalInstall: () => "/fake/prefix/@acme/widget/package.json",
1199
+ });
1200
+ expect(code).toBe(1);
1201
+ expect(logs.join("\n")).toMatch(/does not ship \.parachute\/module\.json/);
1202
+ expect(logs.join("\n")).toMatch(/module-json-extensibility\.md/);
1203
+ } finally {
1204
+ cleanup();
1205
+ }
1206
+ });
1207
+
1208
+ test("third-party module name colliding with first-party shortname is rejected", async () => {
1209
+ const { path, cleanup } = makeTempPath();
1210
+ try {
1211
+ const logs: string[] = [];
1212
+ const code = await install("@evil/squatter", {
1213
+ runner: async () => 0,
1214
+ manifestPath: path,
1215
+ startService: async () => 0,
1216
+ isLinked: () => false,
1217
+ portProbe: async () => false,
1218
+ log: (l) => logs.push(l),
1219
+ readManifest: async () => ({
1220
+ name: "vault",
1221
+ manifestName: "@evil/squatter",
1222
+ kind: "api",
1223
+ port: 1950,
1224
+ paths: ["/vault"],
1225
+ health: "/healthz",
1226
+ }),
1227
+ findGlobalInstall: () => "/fake/prefix/@evil/squatter/package.json",
1228
+ });
1229
+ expect(code).toBe(1);
1230
+ expect(logs.join("\n")).toMatch(/collides with a first-party/);
1231
+ } finally {
1232
+ cleanup();
1233
+ }
1234
+ });
1235
+
1236
+ test("local absolute path resolves package name + module.json", async () => {
1237
+ const { path, cleanup } = makeTempPath();
1238
+ const pkgDir = mkdtempSync(join(tmpdir(), "pcli-localpkg-"));
1239
+ try {
1240
+ const calls: string[][] = [];
1241
+ const logs: string[] = [];
1242
+ const code = await install(pkgDir, {
1243
+ runner: async (cmd) => {
1244
+ calls.push([...cmd]);
1245
+ return 0;
1246
+ },
1247
+ manifestPath: path,
1248
+ startService: async () => 0,
1249
+ isLinked: () => false,
1250
+ portProbe: async () => false,
1251
+ log: (l) => logs.push(l),
1252
+ readManifest: async () => ({
1253
+ name: "demo",
1254
+ manifestName: "@local/demo",
1255
+ kind: "api",
1256
+ port: 1951,
1257
+ paths: ["/demo"],
1258
+ health: "/healthz",
1259
+ }),
1260
+ readPackageName: () => "@local/demo",
1261
+ });
1262
+ expect(code).toBe(0);
1263
+ expect(calls[0]).toEqual(["bun", "add", "-g", pkgDir]);
1264
+ // hub#85: third-party row keys by `manifest.name`, not `manifestName`.
1265
+ const seeded = findService("demo", path);
1266
+ expect(seeded?.name).toBe("demo");
1267
+ expect(findService("@local/demo", path)).toBeUndefined();
1268
+ // hub#83: lifecycle needs installDir to find module.json + spawn cwd.
1269
+ expect(seeded?.installDir).toBe(pkgDir);
1270
+ } finally {
1271
+ cleanup();
1272
+ rmSync(pkgDir, { recursive: true, force: true });
1273
+ }
1274
+ });
1275
+
1276
+ test("local-path re-install skips bun add when symlink already points at it (hub#89)", async () => {
1277
+ // Reproduces the lockfile-pollution loop from hub#89: every
1278
+ // `parachute install /path/to/checkout` shells out `bun add -g
1279
+ // /path/to/checkout`, which appends a duplicate dependency to
1280
+ // ~/.bun/install/global/package.json. After ~5 re-installs bun's
1281
+ // lockfile parser gives up. Fix: if the global symlink already
1282
+ // resolves to this exact path, the install is a no-op for bun-add.
1283
+ const { path, cleanup } = makeTempPath();
1284
+ const pkgDir = mkdtempSync(join(tmpdir(), "pcli-localpkg-"));
1285
+ // macOS tmpdir is symlinked (/var → /private/var); install resolves
1286
+ // both sides via realpath, so the stub must too.
1287
+ const pkgDirReal = realpathSync(pkgDir);
1288
+ try {
1289
+ const calls: string[][] = [];
1290
+ const logs: string[] = [];
1291
+ const code = await install(pkgDir, {
1292
+ runner: async (cmd) => {
1293
+ calls.push([...cmd]);
1294
+ return 0;
1295
+ },
1296
+ manifestPath: path,
1297
+ startService: async () => 0,
1298
+ isLinked: () => false,
1299
+ // The symlink at <bun-globals>/node_modules/@local/demo already
1300
+ // points at the same checkout we're installing — second-+ run.
1301
+ linkedPath: (pkg) => (pkg === "@local/demo" ? pkgDirReal : null),
1302
+ portProbe: async () => false,
1303
+ log: (l) => logs.push(l),
1304
+ readManifest: async () => ({
1305
+ name: "demo",
1306
+ manifestName: "@local/demo",
1307
+ kind: "api",
1308
+ port: 1951,
1309
+ paths: ["/demo"],
1310
+ health: "/healthz",
1311
+ }),
1312
+ readPackageName: () => "@local/demo",
1313
+ });
1314
+ expect(code).toBe(0);
1315
+ // No `bun add -g <pkgDir>` invocation — that's the whole point.
1316
+ const bunAddCalls = calls.filter((c) => c[0] === "bun" && c[1] === "add");
1317
+ expect(bunAddCalls).toEqual([]);
1318
+ // Downstream init/seed/installDir wiring still ran.
1319
+ const seeded = findService("demo", path);
1320
+ expect(seeded?.installDir).toBe(pkgDir);
1321
+ // Operator-visible breadcrumb so they understand why `bun add` was skipped.
1322
+ expect(logs.join("\n")).toMatch(/already linked at .* — skipping bun add/);
1323
+ } finally {
1324
+ cleanup();
1325
+ rmSync(pkgDir, { recursive: true, force: true });
1326
+ }
1327
+ });
1328
+
1329
+ test("local-path install still bun-adds when symlink points elsewhere (hub#89)", async () => {
1330
+ // Operator moved their checkout: the global symlink is stale, pointing at
1331
+ // a different abspath. Re-run bun add against the new path so the link
1332
+ // gets refreshed (don't silently keep using the old target).
1333
+ const { path, cleanup } = makeTempPath();
1334
+ const pkgDir = mkdtempSync(join(tmpdir(), "pcli-localpkg-"));
1335
+ try {
1336
+ const calls: string[][] = [];
1337
+ const code = await install(pkgDir, {
1338
+ runner: async (cmd) => {
1339
+ calls.push([...cmd]);
1340
+ return 0;
1341
+ },
1342
+ manifestPath: path,
1343
+ startService: async () => 0,
1344
+ isLinked: () => false,
1345
+ linkedPath: () => "/Users/someone/old/checkout",
1346
+ portProbe: async () => false,
1347
+ log: () => {},
1348
+ readManifest: async () => ({
1349
+ name: "demo",
1350
+ manifestName: "@local/demo",
1351
+ kind: "api",
1352
+ port: 1951,
1353
+ paths: ["/demo"],
1354
+ health: "/healthz",
1355
+ }),
1356
+ readPackageName: () => "@local/demo",
1357
+ });
1358
+ expect(code).toBe(0);
1359
+ expect(calls[0]).toEqual(["bun", "add", "-g", pkgDir]);
1360
+ } finally {
1361
+ cleanup();
1362
+ rmSync(pkgDir, { recursive: true, force: true });
1363
+ }
1364
+ });
1365
+
1366
+ test("npm-installed third-party module persists installDir from bun globals", async () => {
1367
+ // hub#83: for `parachute install <npm-pkg>`, installDir is dirname of
1368
+ // the package.json that findGlobalInstall returns. Without this,
1369
+ // lifecycle has no way to locate the module's `.parachute/module.json`
1370
+ // and third-party `parachute start <name>` fails post-install.
1371
+ const { path, cleanup } = makeTempPath();
1372
+ try {
1373
+ const fakePrefix = "/fake/bun-globals/node_modules/@vendor/widget";
1374
+ const code = await install("@vendor/widget", {
1375
+ runner: async () => 0,
1376
+ manifestPath: path,
1377
+ startService: async () => 0,
1378
+ isLinked: () => false,
1379
+ portProbe: async () => false,
1380
+ log: () => {},
1381
+ readManifest: async () => ({
1382
+ name: "widget",
1383
+ manifestName: "@vendor/widget",
1384
+ kind: "api",
1385
+ port: 1952,
1386
+ paths: ["/widget"],
1387
+ health: "/widget/health",
1388
+ }),
1389
+ findGlobalInstall: () => `${fakePrefix}/package.json`,
1390
+ });
1391
+ expect(code).toBe(0);
1392
+ // hub#85: third-party row keys by `manifest.name`, not `manifestName`.
1393
+ const seeded = findService("widget", path);
1394
+ expect(seeded?.installDir).toBe(fakePrefix);
1395
+ expect(findService("@vendor/widget", path)).toBeUndefined();
1396
+ } finally {
1397
+ cleanup();
1398
+ }
1399
+ });
1400
+
1401
+ test("third-party with diverging name/manifestName keys services.json by name (hub#85)", async () => {
1402
+ // Repro for parachute-hub#85: parachute-agent ships `name: "agent",
1403
+ // manifestName: "parachute-agent"`. Install used to seed services.json
1404
+ // under `parachute-agent` (the npm label) while lifecycle looks up by
1405
+ // `agent` (the canonical short) → "unknown service". Fix: services.json
1406
+ // key is always `manifest.name` for third-party.
1407
+ const { path, configDir, cleanup } = makeTempPath();
1408
+ try {
1409
+ const startCalls: string[] = [];
1410
+ const logs: string[] = [];
1411
+ const code = await install("parachute-agent", {
1412
+ runner: async () => 0,
1413
+ manifestPath: path,
1414
+ configDir,
1415
+ startService: async (short) => {
1416
+ startCalls.push(short);
1417
+ return 0;
1418
+ },
1419
+ isLinked: () => false,
1420
+ portProbe: async () => false,
1421
+ log: (l) => logs.push(l),
1422
+ readManifest: async () => ({
1423
+ name: "agent",
1424
+ manifestName: "parachute-agent",
1425
+ kind: "api",
1426
+ port: 1945,
1427
+ paths: ["/agent"],
1428
+ health: "/agent/health",
1429
+ startCmd: ["bun", "server.ts"],
1430
+ }),
1431
+ findGlobalInstall: () => "/fake/prefix/parachute-agent/package.json",
1432
+ });
1433
+ expect(code).toBe(0);
1434
+ // services.json is keyed by `name`, not `manifestName`.
1435
+ expect(findService("agent", path)?.name).toBe("agent");
1436
+ expect(findService("parachute-agent", path)).toBeUndefined();
1437
+ // Auto-start receives the canonical short name (= manifest.name).
1438
+ expect(startCalls).toEqual(["agent"]);
1439
+ // Log lines speak in the canonical short name too. Port comes from
1440
+ // assignServicePort (third-party gets the first unassigned canonical
1441
+ // slot, currently 1944), not the manifest's port hint.
1442
+ const joined = logs.join("\n");
1443
+ expect(joined).toMatch(/Seeded services\.json entry for agent/);
1444
+ expect(joined).toMatch(/agent registered on port \d+/);
1445
+ expect(joined).not.toMatch(/Seeded services\.json entry for parachute-agent/);
1446
+ } finally {
1447
+ cleanup();
1448
+ }
1449
+ });
1450
+
1451
+ test("local absolute path that doesn't exist fails fast", async () => {
1452
+ const { path, cleanup } = makeTempPath();
1453
+ try {
1454
+ const logs: string[] = [];
1455
+ const code = await install("/nonexistent/path/xyz", {
1456
+ runner: async () => 0,
1457
+ manifestPath: path,
1458
+ startService: async () => 0,
1459
+ isLinked: () => false,
1460
+ portProbe: async () => false,
1461
+ log: (l) => logs.push(l),
1462
+ });
1463
+ expect(code).toBe(1);
1464
+ expect(logs.join("\n")).toMatch(/path does not exist/);
1465
+ } finally {
1466
+ cleanup();
1467
+ }
1468
+ });
1145
1469
  });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { generateKeyPairSync } from "node:crypto";
3
+ import { pemToJwk } from "../jwks.ts";
4
+
5
+ function freshRsaPem(): string {
6
+ const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
7
+ return publicKey.export({ format: "pem", type: "spki" }).toString();
8
+ }
9
+
10
+ describe("pemToJwk", () => {
11
+ test("returns a JWK with kty=RSA, alg=RS256, use=sig, and the supplied kid", () => {
12
+ const pem = freshRsaPem();
13
+ const jwk = pemToJwk(pem, "test-kid");
14
+ expect(jwk.kty).toBe("RSA");
15
+ expect(jwk.alg).toBe("RS256");
16
+ expect(jwk.use).toBe("sig");
17
+ expect(jwk.kid).toBe("test-kid");
18
+ // n + e are non-empty base64url strings.
19
+ expect(jwk.n.length).toBeGreaterThan(0);
20
+ expect(jwk.e.length).toBeGreaterThan(0);
21
+ expect(jwk.n).not.toMatch(/[+/=]/);
22
+ expect(jwk.e).not.toMatch(/[+/=]/);
23
+ });
24
+
25
+ test("rejects a non-RSA key", () => {
26
+ const { publicKey } = generateKeyPairSync("ed25519");
27
+ const pem = publicKey.export({ format: "pem", type: "spki" }).toString();
28
+ expect(() => pemToJwk(pem, "bad")).toThrow();
29
+ });
30
+
31
+ test("is deterministic for the same PEM + kid", () => {
32
+ const pem = freshRsaPem();
33
+ const a = pemToJwk(pem, "k");
34
+ const b = pemToJwk(pem, "k");
35
+ expect(b).toEqual(a);
36
+ });
37
+ });