@openparachute/hub 0.5.2 → 0.5.9-rc.6

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -114,6 +114,27 @@ export interface ModuleManifest {
114
114
  * as `hasAuth` / `init` / `urlForEntry`.
115
115
  */
116
116
  readonly managementUrl?: string;
117
+ /**
118
+ * Where the module's primary user-facing UI lives. Hub renders a tile on
119
+ * the discovery page (`/`) Services section when set (see
120
+ * `parachute-patterns/patterns/module-json-extensibility.md` and the
121
+ * `loadUiUrls` resolver in `hub-server.ts`).
122
+ *
123
+ * Two shapes — same rules as `managementUrl`:
124
+ * - A relative path (e.g. `"/notes"`, `"/agent"`) — hub resolves
125
+ * against the canonical hub origin.
126
+ * - A full absolute URL — hub uses verbatim.
127
+ *
128
+ * Absent = no Services tile rendered (the module is API-only or surfaces
129
+ * its UI via a sibling module — e.g. vault content browses through Notes,
130
+ * so vault has no `uiUrl`).
131
+ *
132
+ * Read at every discovery render via `installDir/.parachute/module.json`
133
+ * (mirrors how `managementUrl` is sourced for vaults). Not persisted in
134
+ * services.json — that file's "services own the write side" semantics
135
+ * would clobber any install-time copy on the next service boot.
136
+ */
137
+ readonly uiUrl?: string;
117
138
  /**
118
139
  * When `true`, the hub's `/<svc>/*` proxy strips the matched mount prefix
119
140
  * before forwarding (so the backend sees `/health` rather than
@@ -374,6 +395,7 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
374
395
  const dependencies = asDependencies(m.dependencies, where);
375
396
  const configSchema = asConfigSchema(m.configSchema, where);
376
397
  const managementUrl = asManagementUrl(m.managementUrl, where);
398
+ const uiUrl = asUiUrl(m.uiUrl, where);
377
399
  let stripPrefix: boolean | undefined;
378
400
  if (m.stripPrefix !== undefined) {
379
401
  if (typeof m.stripPrefix !== "boolean") {
@@ -396,6 +418,9 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
396
418
  if (managementUrl !== undefined) {
397
419
  (out as { managementUrl?: string }).managementUrl = managementUrl;
398
420
  }
421
+ if (uiUrl !== undefined) {
422
+ (out as { uiUrl?: string }).uiUrl = uiUrl;
423
+ }
399
424
  if (stripPrefix !== undefined) {
400
425
  (out as { stripPrefix?: boolean }).stripPrefix = stripPrefix;
401
426
  }
@@ -403,26 +428,39 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
403
428
  }
404
429
 
405
430
  function asManagementUrl(v: unknown, where: string): string | undefined {
431
+ return asPathOrUrl(v, where, "managementUrl");
432
+ }
433
+
434
+ function asUiUrl(v: unknown, where: string): string | undefined {
435
+ return asPathOrUrl(v, where, "uiUrl");
436
+ }
437
+
438
+ /**
439
+ * Validate a "path or http(s) URL" field. Both `managementUrl` and `uiUrl`
440
+ * follow the same shape per the module-json-extensibility pattern doc;
441
+ * factored so the next URL-shaped field doesn't have to copy-paste.
442
+ */
443
+ function asPathOrUrl(v: unknown, where: string, field: string): string | undefined {
406
444
  if (v === undefined) return undefined;
407
445
  if (typeof v !== "string" || v.length === 0) {
408
- throw new ModuleManifestError(
409
- `${where}: "managementUrl" must be a non-empty string if present`,
410
- );
411
- }
412
- // Two valid shapes: a path starting with "/" or a full http(s) URL.
413
- if (v.startsWith("/")) return v;
446
+ throw new ModuleManifestError(`${where}: "${field}" must be a non-empty string if present`);
447
+ }
448
+ // Two valid shapes: an absolute path starting with a single "/" or a full
449
+ // http(s) URL. Reject protocol-relative forms like "//evil.com" — they
450
+ // start with "/" but `new URL("//evil.com", base)` resolves to the foreign
451
+ // origin, which would let a malicious module render an off-origin tile and
452
+ // turn the discovery page into an open-redirect surface.
453
+ if (v.startsWith("/") && !v.startsWith("//")) return v;
414
454
  try {
415
455
  const u = new URL(v);
416
456
  if (u.protocol !== "http:" && u.protocol !== "https:") {
417
- throw new ModuleManifestError(
418
- `${where}: "managementUrl" absolute form must use http: or https:`,
419
- );
457
+ throw new ModuleManifestError(`${where}: "${field}" absolute form must use http: or https:`);
420
458
  }
421
459
  return v;
422
460
  } catch (err) {
423
461
  if (err instanceof ModuleManifestError) throw err;
424
462
  throw new ModuleManifestError(
425
- `${where}: "managementUrl" must be a path starting with "/" or a full http(s) URL`,
463
+ `${where}: "${field}" must be a path starting with "/" or a full http(s) URL`,
426
464
  );
427
465
  }
428
466
  }
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import { existsSync } from "node:fs";
28
+ import { homedir } from "node:os";
28
29
  import { dirname, join, resolve } from "node:path";
29
30
 
30
31
  interface Args {
@@ -67,16 +68,76 @@ export function normalizeMount(raw: string): string {
67
68
  return raw.replace(/\/+$/, "");
68
69
  }
69
70
 
70
- function resolveNotesDist(): string {
71
- const pkgPath = Bun.resolveSync("@openparachute/notes/package.json", process.cwd());
72
- const root = dirname(pkgPath);
73
- const dist = join(root, "dist");
74
- if (!existsSync(dist)) {
75
- throw new Error(
76
- `@openparachute/notes is installed but has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the notes maintainer to add a prepublishOnly build step.`,
77
- );
71
+ /**
72
+ * Candidate base directories that `Bun.resolveSync` walks from when looking
73
+ * for `@openparachute/notes/package.json`. Order matters:
74
+ *
75
+ * 1. `process.cwd()` — works when notes-serve is invoked from inside the
76
+ * notes checkout (e.g. via `installDir` cwd in lifecycle.ts) or from
77
+ * any project that depends on `@openparachute/notes`.
78
+ * 2. `~/.bun/install/global/node_modules` — modern Bun's global-install
79
+ * layout. This is where `bun add -g @openparachute/notes` lands the
80
+ * package, and where `bun link @openparachute/notes` symlinks it.
81
+ * 3. `~/.bun/install/global` — defensive fallback for older Bun layouts.
82
+ *
83
+ * Hub itself does NOT depend on `@openparachute/notes`, so when
84
+ * `parachute start notes` is run from the hub repo dir, the cwd-relative
85
+ * resolve walks ancestral node_modules and finds nothing. Bun does not
86
+ * auto-consult the global install dir, so bun-linked installs fail to
87
+ * resolve without (2)/(3). hub#194: Aaron hit silent 502 on tailnet
88
+ * `/notes/` because of this — fixed by trying the global install dirs.
89
+ *
90
+ * Exported (and parameterized via `cwd`/`home`) so tests can drive the
91
+ * resolution order against a real fixture install without monkey-patching
92
+ * `Bun.resolveSync`.
93
+ */
94
+ export function notesDistCandidates(cwd: string, home: string): string[] {
95
+ return [cwd, join(home, ".bun/install/global/node_modules"), join(home, ".bun/install/global")];
96
+ }
97
+
98
+ export interface ResolveNotesDistDeps {
99
+ cwd?: string;
100
+ home?: string;
101
+ /** Override `Bun.resolveSync` for tests. */
102
+ resolveSync?: (specifier: string, base: string) => string;
103
+ existsSync?: (path: string) => boolean;
104
+ }
105
+
106
+ export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
107
+ const cwd = deps.cwd ?? process.cwd();
108
+ const home = deps.home ?? homedir();
109
+ const resolveSync = deps.resolveSync ?? Bun.resolveSync;
110
+ const exists = deps.existsSync ?? existsSync;
111
+ const candidates = notesDistCandidates(cwd, home);
112
+ const resolveErrors: string[] = [];
113
+ for (const base of candidates) {
114
+ let pkgPath: string;
115
+ try {
116
+ pkgPath = resolveSync("@openparachute/notes/package.json", base);
117
+ } catch (err) {
118
+ resolveErrors.push(` - ${base}: ${err instanceof Error ? err.message : String(err)}`);
119
+ continue;
120
+ }
121
+ const root = dirname(pkgPath);
122
+ const dist = join(root, "dist");
123
+ if (!exists(dist)) {
124
+ // Found the package but it has no dist/. This is a hard error
125
+ // (package shipped without a prebuilt bundle); don't fall through to
126
+ // other candidates — they'd resolve to the same package and report
127
+ // the same problem.
128
+ throw new Error(
129
+ `@openparachute/notes resolved at ${root} has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the notes maintainer to add a prepublishOnly build step.`,
130
+ );
131
+ }
132
+ return dist;
78
133
  }
79
- return dist;
134
+ throw new Error(
135
+ `Could not resolve @openparachute/notes from any of:\n${resolveErrors.join("\n")}\nIs the package installed? Try \`bun add -g @openparachute/notes\` or \`parachute install notes\`.`,
136
+ );
137
+ }
138
+
139
+ function resolveNotesDist(): string {
140
+ return resolveNotesDistFrom();
80
141
  }
81
142
 
82
143
  function mimeFor(path: string): string | undefined {