@openparachute/hub 0.6.5-rc.8 → 0.7.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.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +310 -6
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
  6. package/src/__tests__/admin-connections.test.ts +1154 -0
  7. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  8. package/src/__tests__/admin-module-token.test.ts +311 -0
  9. package/src/__tests__/admin-vaults.test.ts +590 -0
  10. package/src/__tests__/api-invites.test.ts +166 -6
  11. package/src/__tests__/api-modules-ops.test.ts +70 -5
  12. package/src/__tests__/api-modules.test.ts +262 -79
  13. package/src/__tests__/audience-gate.test.ts +752 -0
  14. package/src/__tests__/hub-db.test.ts +36 -0
  15. package/src/__tests__/hub-server.test.ts +585 -21
  16. package/src/__tests__/invites.test.ts +91 -1
  17. package/src/__tests__/lifecycle.test.ts +238 -3
  18. package/src/__tests__/module-manifest.test.ts +305 -8
  19. package/src/__tests__/serve-boot.test.ts +133 -2
  20. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  21. package/src/__tests__/setup-gate.test.ts +13 -7
  22. package/src/__tests__/setup-wizard.test.ts +228 -1
  23. package/src/__tests__/vault-name.test.ts +20 -5
  24. package/src/__tests__/well-known.test.ts +44 -8
  25. package/src/__tests__/ws-bridge.test.ts +573 -0
  26. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  27. package/src/account-setup.ts +94 -23
  28. package/src/account-vault-admin-token.ts +43 -14
  29. package/src/admin-channel-token.ts +135 -0
  30. package/src/admin-connections.ts +1882 -0
  31. package/src/admin-login-ui.ts +64 -15
  32. package/src/admin-module-token.ts +197 -0
  33. package/src/admin-vaults.ts +399 -12
  34. package/src/api-hub-upgrade.ts +4 -3
  35. package/src/api-invites.ts +92 -12
  36. package/src/api-modules-ops.ts +41 -16
  37. package/src/api-modules.ts +238 -116
  38. package/src/api-tokens.ts +8 -5
  39. package/src/audience-gate.ts +268 -0
  40. package/src/chrome-strip.ts +8 -1
  41. package/src/commands/lifecycle.ts +187 -47
  42. package/src/commands/serve-boot.ts +80 -3
  43. package/src/commands/setup.ts +4 -4
  44. package/src/connections-store.ts +191 -0
  45. package/src/grants.ts +50 -0
  46. package/src/help.ts +13 -6
  47. package/src/host-admin-token-validation.ts +6 -2
  48. package/src/hub-db.ts +26 -1
  49. package/src/hub-server.ts +849 -70
  50. package/src/invites.ts +91 -2
  51. package/src/jwt-sign.ts +47 -1
  52. package/src/module-manifest.ts +536 -23
  53. package/src/origin-check.ts +109 -0
  54. package/src/proxy-error-ui.ts +1 -1
  55. package/src/service-spec.ts +132 -41
  56. package/src/services-manifest.ts +97 -0
  57. package/src/setup-wizard.ts +68 -6
  58. package/src/users.ts +11 -0
  59. package/src/vault-name.ts +27 -7
  60. package/src/well-known.ts +41 -33
  61. package/src/ws-bridge.ts +256 -0
  62. package/src/ws-connection-caps.ts +170 -0
  63. package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
  64. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/src/__tests__/api-modules-config.test.ts +0 -882
  67. package/src/api-modules-config.ts +0 -421
  68. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  69. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -77,10 +77,12 @@ export interface AccountVaultAdminTokenDeps {
77
77
  hubOrigin: string;
78
78
  /**
79
79
  * The vault's declared `managementUrl` (from its `.parachute/module.json`),
80
- * resolved by the route handler at request time. Either an absolute URL or a
81
- * path relative to the vault's mounted URL. Defaults to `/admin/` (vault's
82
- * canonical value) when the handler can't resolve one that's where the
83
- * admin sibling's deep-link lands too.
80
+ * resolved by the route handler at request time. Resolved per the B4
81
+ * unified semantics (http(s):// verbatim · leading-`/` origin-absolute ·
82
+ * relative joined under the vault's mounted URL; the literal legacy
83
+ * `"/admin/"` mount-joins via the one-release compat shim). Defaults to
84
+ * `"admin/"` (vault's canonical per-instance value) when the handler can't
85
+ * resolve one — that's where the admin sibling's deep-link lands too.
84
86
  */
85
87
  managementUrl?: string;
86
88
  /** Test seam for the clock (mint). */
@@ -94,17 +96,43 @@ function htmlResponse(body: string, status = 200, extra: Record<string, string>
94
96
  });
95
97
  }
96
98
 
99
+ /** One-time deprecation log for the legacy `"/admin/"` managementUrl (B4 compat shim). */
100
+ let warnedLegacyManagementUrl = false;
101
+
97
102
  /**
98
103
  * Resolve a vault's `managementUrl` against the vault's hub-mounted URL.
99
- * Absolute URL returned verbatim; path → joined onto the vault URL after
100
- * trimming a trailing slash. Mirrors `resolveManagementUrl` in the SPA's
101
- * `web/ui/src/lib/api.ts` so hub-server and SPA deep-links agree.
104
+ * Unified URL-resolution semantics (B4 of the 2026-06-09 hub-module-boundary
105
+ * migration) mirrors `resolveManagementUrl` in the SPA's
106
+ * `web/ui/src/lib/api.ts` so hub-server and SPA deep-links agree:
107
+ *
108
+ * - Absolute http(s) URL → verbatim.
109
+ * - Leading-`/` path → ORIGIN-ABSOLUTE: resolved against the vault URL's
110
+ * origin (not joined under the vault mount).
111
+ * - Relative path (no leading slash, e.g. `"admin/"`) → the PER-INSTANCE
112
+ * form: joined under the vault's mounted URL
113
+ * (`<origin>/vault/<name>/admin/`).
114
+ *
115
+ * COMPAT SHIM (one release — remove once vault's new manifest reaches
116
+ * @latest): the literal legacy `"/admin"`/`"/admin/"` is the OLD per-instance
117
+ * relative declaration deployed vaults still ship; it joins under the vault
118
+ * URL (the pre-B4 behavior) with a one-time deprecation log.
102
119
  */
103
120
  function resolveManagementUrl(vaultUrl: string, managementUrl: string): string {
104
121
  if (/^https?:\/\//i.test(managementUrl)) return managementUrl;
105
122
  const base = vaultUrl.replace(/\/+$/, "");
106
- const tail = managementUrl.startsWith("/") ? managementUrl : `/${managementUrl}`;
107
- return `${base}${tail}`;
123
+ if (managementUrl === "/admin" || managementUrl === "/admin/") {
124
+ if (!warnedLegacyManagementUrl) {
125
+ warnedLegacyManagementUrl = true;
126
+ console.warn(
127
+ `account-vault-admin-token: vault declares the legacy per-instance managementUrl ${JSON.stringify(managementUrl)}; joining under the vault URL for one release. New semantics: relative ("admin/") = per-instance join, leading-"/" = origin-absolute. Upgrade the vault module to clear this.`,
128
+ );
129
+ }
130
+ return `${base}${managementUrl}`;
131
+ }
132
+ if (managementUrl.startsWith("/")) {
133
+ return new URL(managementUrl, `${base}/`).toString();
134
+ }
135
+ return `${base}/${managementUrl}`;
108
136
  }
109
137
 
110
138
  export async function handleAccountVaultAdminTokenPost(
@@ -224,14 +252,15 @@ export async function handleAccountVaultAdminTokenPost(
224
252
  ...(deps.now !== undefined ? { now: deps.now } : {}),
225
253
  });
226
254
 
227
- // Build the redirect target: <vault-url><managementUrl>#token=<jwt>. The
255
+ // Build the redirect target: <vault-url>/<managementUrl>#token=<jwt>. The
228
256
  // vault URL is the hub-mounted path (`<hubOrigin>/vault/<name>`); the
229
- // managementUrl (default `/admin/`) is the vault admin SPA entry point. The
230
- // JWT rides the URL fragment never sent to the server exactly as the hub
231
- // SPA's "Manage" button does (vault PR #219).
257
+ // managementUrl (default `"admin/"` the per-instance relative form under
258
+ // the B4 semantics) is the vault admin SPA entry point. The JWT rides the
259
+ // URL fragment never sent to the server — exactly as the hub SPA's
260
+ // "Manage" button does (vault PR #219).
232
261
  const trimmedOrigin = deps.hubOrigin.replace(/\/+$/, "");
233
262
  const vaultUrl = `${trimmedOrigin}/vault/${vaultName}`;
234
- const target = resolveManagementUrl(vaultUrl, deps.managementUrl ?? "/admin/");
263
+ const target = resolveManagementUrl(vaultUrl, deps.managementUrl ?? "admin/");
235
264
  const sep = target.includes("#") ? "&" : "#";
236
265
  const location = `${target}${sep}token=${minted.token}`;
237
266
 
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `GET /admin/channel-token` — exchange a valid admin session cookie for a
3
+ * short-lived JWT carrying `channel:read channel:send channel:admin`.
4
+ *
5
+ * Why this exists: two channel-owned UIs, both served behind hub's proxy to a
6
+ * logged-in portal operator, need a Bearer to talk to channel's API the same
7
+ * way the vault-management and scribe-config SPAs do, without running the
8
+ * public `/oauth/authorize` flow:
9
+ * - The **chat UI** (`/channel/ui`) receives replies over SSE
10
+ * (`channel:read`) and posts a message (`channel:send`).
11
+ * - The **config/admin UI** (`/channel/admin`, the 2026-06-09 modular-UI
12
+ * architecture P3/P4 config surface) lists + edits configured channels via
13
+ * `channel:admin`-gated endpoints (`requireScope(SCOPE_ADMIN)` in channel's
14
+ * daemon).
15
+ *
16
+ * Both UIs fetch this single endpoint (`fetchToken()` against
17
+ * `/admin/channel-token`), so the minted token carries the union of the scopes
18
+ * either UI needs. The chat UI simply ignores the extra `channel:admin` scope;
19
+ * `requireScope` checks for the *presence* of a specific scope, so extra
20
+ * scopes never break a read/send call. This is what makes the channel config
21
+ * UI work without re-touching the channel repo — the hub endpoint the config
22
+ * UI already calls now mints the admin scope it needs (2026-06-09 modular-UI
23
+ * architecture, P3).
24
+ *
25
+ * Scope choice — `channel:read channel:send channel:admin`, deliberately NOT
26
+ * `channel:write`:
27
+ * - `channel:read` — receive replies over SSE.
28
+ * - `channel:send` — post a message into the channel.
29
+ * - `channel:admin` — list + edit channel config (the config UI).
30
+ * - `channel:write` is the *session-reply* scope (a connected Claude Code
31
+ * session replying on a channel). A UI token must not be able to
32
+ * impersonate a session, so we never mint `channel:write` here.
33
+ *
34
+ * Audience: `channel` (the bare service prefix). Channel validates the JWT's
35
+ * `aud` claim against the literal string `"channel"` (parachute-channel
36
+ * `src/hub-jwt.ts` `CHANNEL_AUDIENCE`), the same shape `inferAudience` in
37
+ * oauth-handlers.ts stamps for the public OAuth flow — so hub-minted and
38
+ * OAuth-minted channel tokens are indistinguishable to channel. Unlike the
39
+ * per-vault admin token (`vault.<name>`), channel has a single bare audience.
40
+ *
41
+ * Multi-user Phase 1 gate: the session must belong to the first admin (the
42
+ * single hub admin under the Phase 1 model — see `users.ts:isFirstAdmin`),
43
+ * mirroring host-admin-token and vault-admin-token. Friends pinned to a vault
44
+ * use the OAuth flow for their assigned scopes; they don't get a channel
45
+ * Bearer via this endpoint.
46
+ *
47
+ * Tokens minted here are short-lived (10 min — matches host/vault admin
48
+ * tokens); the UI re-fetches on near-expiry.
49
+ */
50
+ import type { Database } from "bun:sqlite";
51
+ import { signAccessToken } from "./jwt-sign.ts";
52
+ import { findSession, parseSessionCookie } from "./sessions.ts";
53
+ import { isFirstAdmin } from "./users.ts";
54
+
55
+ /** Short TTL — matches host/vault admin-token. UI re-fetches on near-expiry. */
56
+ export const CHANNEL_TOKEN_TTL_SECONDS = 10 * 60;
57
+ const CHANNEL_AUDIENCE = "channel";
58
+ const CHANNEL_CLIENT_ID = "parachute-hub-spa";
59
+ /**
60
+ * `channel:read` (SSE replies) + `channel:send` (post a message) +
61
+ * `channel:admin` (list + edit channel config — the config UI). Deliberately
62
+ * NOT `channel:write` — that's the session-reply scope, and a UI token must
63
+ * not be able to impersonate a connected session. The chat UI ignores the
64
+ * extra `channel:admin`; the config UI needs it (2026-06-09 modular-UI
65
+ * architecture, P3 — the hub endpoint the channel config UI already calls
66
+ * mints the admin scope so the channel repo doesn't have to change).
67
+ */
68
+ export const CHANNEL_TOKEN_SCOPES = ["channel:read", "channel:send", "channel:admin"] as const;
69
+
70
+ export interface MintChannelTokenDeps {
71
+ db: Database;
72
+ /** Hub origin — written into JWT `iss`. */
73
+ issuer: string;
74
+ }
75
+
76
+ export async function handleChannelToken(
77
+ req: Request,
78
+ deps: MintChannelTokenDeps,
79
+ ): Promise<Response> {
80
+ if (req.method !== "GET") {
81
+ return jsonError(405, "method_not_allowed", "use GET");
82
+ }
83
+ const sid = parseSessionCookie(req.headers.get("cookie"));
84
+ const session = sid ? findSession(deps.db, sid) : null;
85
+ if (!session) {
86
+ return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
87
+ }
88
+ // First-admin gate (mirrors host/vault-admin-token). A friend account
89
+ // (non-first-admin user created via `/api/users`) holds a valid session but
90
+ // must not mint a channel Bearer. Without this check, any signed-in friend
91
+ // hitting `GET /admin/channel-token` would walk away with a token carrying
92
+ // `channel:read channel:send`.
93
+ if (!isFirstAdmin(deps.db, session.userId)) {
94
+ return jsonError(
95
+ 403,
96
+ "not_admin",
97
+ "channel token mint is restricted to the hub admin — your account home is at /account/",
98
+ );
99
+ }
100
+ const minted = await signAccessToken(deps.db, {
101
+ sub: session.userId,
102
+ scopes: [...CHANNEL_TOKEN_SCOPES],
103
+ audience: CHANNEL_AUDIENCE,
104
+ clientId: CHANNEL_CLIENT_ID,
105
+ issuer: deps.issuer,
106
+ ttlSeconds: CHANNEL_TOKEN_TTL_SECONDS,
107
+ // Channel tokens carry no per-user vault pin — the UI Bearer talks to a
108
+ // channel-scoped endpoint, not to a single vault. Empty `vault_scope` is
109
+ // the "no per-user restriction" sentinel matching host-admin tokens.
110
+ vaultScope: [],
111
+ });
112
+ return new Response(
113
+ JSON.stringify({
114
+ token: minted.token,
115
+ expires_at: minted.expiresAt,
116
+ scopes: CHANNEL_TOKEN_SCOPES,
117
+ }),
118
+ {
119
+ status: 200,
120
+ headers: {
121
+ "content-type": "application/json",
122
+ // No browser cache — token rotates per-fetch, and a stale 200 from a
123
+ // back/forward navigation could hand the UI a long-expired JWT.
124
+ "cache-control": "no-store",
125
+ },
126
+ },
127
+ );
128
+ }
129
+
130
+ function jsonError(status: number, error: string, description: string): Response {
131
+ return new Response(JSON.stringify({ error, error_description: description }), {
132
+ status,
133
+ headers: { "content-type": "application/json" },
134
+ });
135
+ }