@openparachute/hub 0.7.0 → 0.7.2-rc.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 (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -71,7 +71,6 @@ import {
71
71
  type IssueOperatorTokenResult,
72
72
  type MintOperatorTokenOpts,
73
73
  issueOperatorToken,
74
- mintOperatorToken,
75
74
  readOperatorTokenFile,
76
75
  } from "./operator-token.ts";
77
76
  import { isHttpsRequest } from "./request-protocol.ts";
@@ -1154,16 +1153,6 @@ export interface RenderDoneStepProps {
1154
1153
  * (e.g. tests of the wizard's older two-step flow).
1155
1154
  */
1156
1155
  exposeMode?: SetupExposeMode;
1157
- /**
1158
- * Auto-minted operator token surfaced once on the done screen
1159
- * (hub#272 Item A). When present, the MCP install command renders
1160
- * with `--header "Authorization: Bearer <token>"` pre-filled and a
1161
- * one-click Copy button. Absent means the mint either failed or
1162
- * the operator already consumed the single-use surface — the tile
1163
- * falls back to the un-headered command + a "mint at /admin/tokens"
1164
- * hint.
1165
- */
1166
- mintedToken?: string;
1167
1156
  /**
1168
1157
  * Optional per-module install tiles to render alongside the MCP
1169
1158
  * command (hub#272 Item B). When omitted, the done step renders
@@ -1175,9 +1164,9 @@ export interface RenderDoneStepProps {
1175
1164
  }
1176
1165
 
1177
1166
  export function renderDoneStep(props: RenderDoneStepProps): string {
1178
- const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles } = props;
1167
+ const { vaultName, hubOrigin, exposeMode, installTiles } = props;
1179
1168
  const reachable = exposeMode ? renderReachableTile(exposeMode, hubOrigin) : "";
1180
- const mcpTile = renderMcpTile(vaultName, hubOrigin, mintedToken);
1169
+ const mcpTile = renderMcpTile(vaultName, hubOrigin);
1181
1170
  const tiles = installTiles && installTiles.length > 0 ? installTiles : [];
1182
1171
  const installSection = tiles.length > 0 ? renderInstallTiles(tiles) : "";
1183
1172
  const startTile = renderStartUsingTile(vaultName, hubOrigin);
@@ -1241,129 +1230,26 @@ export function renderDoneStep(props: RenderDoneStepProps): string {
1241
1230
  }
1242
1231
 
1243
1232
  /**
1244
- * The MCP-connect tile. With a freshly-minted token the command renders
1245
- * fully formed with a `--header "Authorization: Bearer <token>"` flag +
1246
- * a Copy button. Without one, we fall back to the bare command + a
1247
- * pointer to `/admin/tokens` (the canonical mint surface). The Copy
1248
- * button is a tiny inline `<script>` no SPA bundle, no module deps,
1249
- * the wizard stays server-rendered.
1233
+ * The MCP-connect tile. Renders the bare `claude mcp add` command
1234
+ * no token, no `--header`. Vault/init is OAuth-default (parachute-vault
1235
+ * #491), so the command triggers browser OAuth on first use: the
1236
+ * operator signs in to this hub + approves access, and Claude Code
1237
+ * stores the resulting credential. For headless clients that can't do
1238
+ * the browser flow, the fine print points at `/admin/tokens` (the
1239
+ * canonical mint surface) + the `--header "Authorization: Bearer
1240
+ * <token>"` form they append themselves.
1241
+ *
1242
+ * History: an earlier build (hub#272 Item A) auto-minted a full-scope
1243
+ * operator token here and pre-filled the command with a Bearer header
1244
+ * + a masked-reveal/Copy widget. That was removed 2026-06-23 (Austen's
1245
+ * report): header-auth was the wrong default once vault went OAuth-
1246
+ * default, and baking an admin-scope bearer into a copy-pasted command
1247
+ * was a privilege over-grant + a shoulder-surf hazard. The bare OAuth
1248
+ * command was always the correct UX; it's now the only one.
1250
1249
  */
1251
- function renderMcpTile(
1252
- vaultName: string,
1253
- hubOrigin: string,
1254
- mintedToken: string | undefined,
1255
- ): string {
1250
+ function renderMcpTile(vaultName: string, hubOrigin: string): string {
1256
1251
  const safeVault = escapeHtml(vaultName);
1257
1252
  const bareCmd = `claude mcp add --transport http parachute-${vaultName} ${hubOrigin}/vault/${vaultName}/mcp`;
1258
- if (mintedToken) {
1259
- // The token contents are surfaced once + then forgotten by the
1260
- // server (single-use hub_setting). Two visible variants of the
1261
- // command live in the DOM:
1262
- //
1263
- // * `pre#mcp-cmd` — what the operator sees. The Bearer token is
1264
- // replaced with a fixed-width row of • so shoulder-surfers,
1265
- // screencasts, and over-the-shoulder photos don't capture
1266
- // credentials by default. This is the "discoverable but not
1267
- // shoulder-surf-able" framing Aaron asked for.
1268
- // * `script#mcp-cmd-real` (type=text/plain) — the real command
1269
- // with the live token, stashed in a non-rendering script tag.
1270
- // The Copy + Show handlers read from this so the operator's
1271
- // terminal paste still gets the real header without the page
1272
- // ever painting the token.
1273
- //
1274
- // The view-source threat model is unchanged from rc.9 — the token
1275
- // is part of the response body either way. The improvement is
1276
- // *visibly hidden by default*, which is what an over-the-shoulder
1277
- // observer needs (and what existing screencasts of the wizard
1278
- // currently leak).
1279
- //
1280
- // Show toggles textContent between masked + real and flips a
1281
- // data-state attribute so a screencast / pair-programming session
1282
- // can briefly reveal-and-rehide without the operator losing the
1283
- // line of sight on which mode they're in. Auto-hide after 10s so
1284
- // a forgotten reveal doesn't leak the token into a subsequent
1285
- // recording.
1286
- const fullCmd = `${bareCmd} --header "Authorization: Bearer ${mintedToken}"`;
1287
- // Clamp the dot count to a 8–40 range so very-short or very-long
1288
- // tokens don't render comically — token format is fixed-width
1289
- // (JTI-derived), so this is purely visual.
1290
- const maskedToken = "•".repeat(Math.max(8, Math.min(40, mintedToken.length)));
1291
- const maskedCmd = `${bareCmd} --header "Authorization: Bearer ${maskedToken}"`;
1292
- // The real command rides in a hidden <script type="application/json">
1293
- // block as a JSON-encoded string. <script> element content is parsed
1294
- // as raw text (no entity references), so HTML escaping would put
1295
- // literal `&quot;` into the string — and Copy would paste that into
1296
- // the operator's terminal. JSON encoding (with `</` escaped so the
1297
- // sequence can't prematurely close the tag) round-trips safely:
1298
- // textContent returns the JSON, JSON.parse decodes back to the
1299
- // exact bytes of the original command. Caught while smoke-testing
1300
- // the rc.11 reveal/copy UX — pre-fix, the copied command included
1301
- // `&quot;` placeholders that broke shell parsing.
1302
- const fullCmdJson = JSON.stringify(fullCmd).replace(/<\//g, "<\\/");
1303
- return `<div class="done-tile">
1304
- <h2>Connect Claude Code (MCP)</h2>
1305
- <p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
1306
- <div class="mcp-cmd-wrap" data-state="masked">
1307
- <pre id="mcp-cmd">${escapeHtml(maskedCmd)}</pre>
1308
- <script type="application/json" id="mcp-cmd-real">${fullCmdJson}</script>
1309
- <div class="mcp-cmd-actions">
1310
- <button type="button" class="btn btn-mcp-aux" id="mcp-cmd-show">Show token</button>
1311
- <button type="button" class="btn btn-copy" id="mcp-cmd-copy">Copy</button>
1312
- </div>
1313
- </div>
1314
- <p class="fine">We minted this token for your first MCP connection.
1315
- It's masked above so it's safe to leave open on screen; Copy
1316
- copies the real command. It's a full-scope operator token tied
1317
- to your admin account; manage and revoke tokens at
1318
- <a href="/admin/tokens"><code>/admin/tokens</code></a>.</p>
1319
- <script>
1320
- (function () {
1321
- var wrap = document.querySelector('.mcp-cmd-wrap[data-state]');
1322
- var pre = document.getElementById('mcp-cmd');
1323
- var real = document.getElementById('mcp-cmd-real');
1324
- var copyBtn = document.getElementById('mcp-cmd-copy');
1325
- var showBtn = document.getElementById('mcp-cmd-show');
1326
- if (!wrap || !pre || !real || !copyBtn || !showBtn) return;
1327
- var realCmd;
1328
- try { realCmd = JSON.parse(real.textContent || '""'); }
1329
- catch (e) { realCmd = ''; }
1330
- var maskedCmd = pre.textContent || '';
1331
- var revealTimer = null;
1332
- function setMasked() {
1333
- pre.textContent = maskedCmd;
1334
- wrap.setAttribute('data-state', 'masked');
1335
- showBtn.textContent = 'Show token';
1336
- if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; }
1337
- }
1338
- function setRevealed() {
1339
- pre.textContent = realCmd;
1340
- wrap.setAttribute('data-state', 'revealed');
1341
- showBtn.textContent = 'Hide token';
1342
- // Auto-hide after 10s so a stray reveal doesn't leak the
1343
- // token into a screencast capture that started after the
1344
- // click.
1345
- if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; }
1346
- revealTimer = setTimeout(setMasked, 10000);
1347
- }
1348
- showBtn.addEventListener('click', function () {
1349
- if (wrap.getAttribute('data-state') === 'masked') setRevealed();
1350
- else setMasked();
1351
- });
1352
- copyBtn.addEventListener('click', function () {
1353
- // Copy ALWAYS pulls from the real command — the operator's
1354
- // terminal needs the live token regardless of whether the
1355
- // page is currently masked. This is the load-bearing path:
1356
- // the visible mask is a UX nicety; the clipboard must
1357
- // carry the real header.
1358
- navigator.clipboard.writeText(realCmd).then(function () {
1359
- copyBtn.textContent = 'Copied ✓';
1360
- setTimeout(function () { copyBtn.textContent = 'Copy'; }, 2000);
1361
- });
1362
- });
1363
- })();
1364
- </script>
1365
- </div>`;
1366
- }
1367
1253
  return `<div class="done-tile">
1368
1254
  <h2>Connect Claude Code (MCP)</h2>
1369
1255
  <p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
@@ -1755,25 +1641,21 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1755
1641
  // request from step 2.
1756
1642
  if (state.hasAdmin && state.hasVault && state.hasExposeMode) {
1757
1643
  if (url.searchParams.get("just_finished") === "1") {
1758
- // hub#274 security fold: session-gate this branch. The
1759
- // `?just_finished=1` GET reads + consumes `setup_minted_token`
1760
- // (full-scope operator JWT) below; without a session check, any
1761
- // HTTP client that races the operator's browser between the
1762
- // expose POST (which writes the row) and the done GET (which
1763
- // reads it) walks off with admin-scope creds. The dispatcher
1764
- // in `hub-server.ts`'s `shouldGateForSetup` lets `/admin/setup*`
1765
- // through the pre-admin lockout, and that path stays open
1766
- // post-setup so this gate has to live here, not at the
1767
- // dispatcher layer.
1644
+ // hub#274 security fold: session-gate this branch. Originally this
1645
+ // protected the `setup_minted_token` read-and-consume below (a
1646
+ // full-scope operator JWT a racing client could have walked off
1647
+ // with). The auto-mint was removed 2026-06-23 (Austen's report
1648
+ // vault is OAuth-default now, see the expose POST), so no secret
1649
+ // is surfaced here anymore. The gate stays: the done screen is a
1650
+ // post-setup admin surface and shouldn't render to a drive-by
1651
+ // unauthenticated GET. The dispatcher in `hub-server.ts`'s
1652
+ // `shouldGateForSetup` lets `/admin/setup*` through the pre-admin
1653
+ // lockout, and that path stays open post-setup — so this gate has
1654
+ // to live here, not at the dispatcher layer.
1768
1655
  //
1769
1656
  // A legitimate operator carrying the session cookie minted on
1770
1657
  // the account POST sails through. A drive-by GET without the
1771
- // cookie 302s to /login: if it's a stale bookmark in the
1772
- // operator's other tab, they sign in + the row is already
1773
- // consumed by the legitimate done-GET (the single-use shape
1774
- // guarantees they see the fallback shape, never the secret).
1775
- // If it's an attacker, they can't pass /login without the
1776
- // password.
1658
+ // cookie 302s to /login.
1777
1659
  const session = findActiveSession(deps.db, req);
1778
1660
  if (!session) {
1779
1661
  // Preserve the CSRF set-cookie header on the 302 — same shape as
@@ -1790,13 +1672,17 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1790
1672
  }
1791
1673
  const stored = getSetting(deps.db, "setup_expose_mode");
1792
1674
  const exposeMode = isSetupExposeMode(stored) ? stored : undefined;
1793
- // hub#272 Item A: read + consume the single-use minted-token row.
1794
- // Render-and-forget keeps the secret from re-appearing on
1795
- // refresh / back-button. The mint is non-fatal (see expose POST);
1796
- // its absence renders the bare MCP command + a hint at
1797
- // /admin/tokens.
1798
- const mintedToken = getSetting(deps.db, "setup_minted_token");
1799
- if (mintedToken) deleteSetting(deps.db, "setup_minted_token");
1675
+ // No minted-token read here anymore the auto-mint was removed
1676
+ // 2026-06-23 (Austen's report). The done step always renders the
1677
+ // bare OAuth `claude mcp add` command (no Bearer header), which is
1678
+ // the correct UX for OAuth-default vaults. Headless clients mint a
1679
+ // scoped token at /admin/tokens themselves (the bare tile points
1680
+ // there). The defensive `deleteSetting` below clears any stale row
1681
+ // a pre-upgrade hub left behind, so it never leaks on first render
1682
+ // after upgrade.
1683
+ if (getSetting(deps.db, "setup_minted_token") !== undefined) {
1684
+ deleteSetting(deps.db, "setup_minted_token");
1685
+ }
1800
1686
  // Prefer the LIVE vault name from services.json over the
1801
1687
  // operator-typed value cached in hub_settings (smoke
1802
1688
  // 2026-05-27, finding 2). The cached value is what the
@@ -1834,7 +1720,7 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1834
1720
  installTiles,
1835
1721
  };
1836
1722
  if (exposeMode !== undefined) doneProps.exposeMode = exposeMode;
1837
- if (mintedToken) doneProps.mintedToken = mintedToken;
1723
+ // No `doneProps.mintedToken` see the OAuth-default note above.
1838
1724
  // Substitute CSRF placeholder for the install-tile forms with
1839
1725
  // the current CSRF token. Keeping the per-tile renderer pure
1840
1726
  // means the substitution lives here (one rewrite per render).
@@ -2861,35 +2747,23 @@ export async function handleSetupExposePost(
2861
2747
  console.log(
2862
2748
  `[setup-wizard] opened first-client auto-approve window (60min) after expose-mode=${rawMode}`,
2863
2749
  );
2864
- // hub#272 Item A: auto-mint an operator token under the broad `admin`
2865
- // scope-set + persist it once so the done-step renderer can pre-fill
2866
- // the MCP install command with a Bearer header. The token is single-
2867
- // use surface on the done page the renderer deletes it from
2868
- // hub_settings after one read so a stale tab refresh / back button
2869
- // doesn't re-disclose the secret. The jti is still in the `tokens`
2870
- // registry so revocation via the admin UI works as usual. Failures
2871
- // are non-fatal: the done page falls back to the un-headered MCP
2872
- // command + a "mint manually at /admin/tokens" hint.
2873
- let mintedTokenForJson: string | undefined;
2874
- try {
2875
- const minted = await mintOperatorToken(deps.db, session.userId, {
2876
- issuer: deps.issuer,
2877
- scopeSet: "admin",
2878
- });
2879
- setSetting(deps.db, "setup_minted_token", minted.token);
2880
- mintedTokenForJson = minted.token;
2881
- console.log(
2882
- `[setup-wizard] auto-minted operator token (jti=${minted.jti}, scope-set=admin) for done-screen MCP command`,
2883
- );
2884
- } catch (err) {
2885
- const msg = err instanceof Error ? err.message : String(err);
2886
- console.warn(`[setup-wizard] failed to auto-mint operator token: ${msg}`);
2887
- }
2750
+ // hub#272 Item A USED to auto-mint a broad `admin`-scope operator token
2751
+ // here + stash it (`setup_minted_token`) so the done screen could
2752
+ // pre-fill the MCP install command with a `--header "Authorization:
2753
+ // Bearer <token>"` flag. Removed 2026-06-23 (Austen's report): vault/
2754
+ // init is OAuth-default now (parachute-vault #491), so the bare
2755
+ // `claude mcp add` command is the correct UX it triggers browser
2756
+ // OAuth on first use. Baking a full admin-scope bearer into a copy-
2757
+ // pasted command was both the wrong default and a privilege over-grant
2758
+ // (a single shoulder-surf / screencast leaked an admin token). The
2759
+ // done step now always renders the bare OAuth command; headless
2760
+ // clients that can't do the browser flow mint a scoped token at
2761
+ // /admin/tokens + append the header themselves (the bare tile points
2762
+ // there). No token is minted or stored by default.
2888
2763
  if (form.isJson) {
2889
2764
  return jsonOkResponse({
2890
2765
  step: "done",
2891
2766
  message: "expose mode set",
2892
- ...(mintedTokenForJson ? { minted_token: mintedTokenForJson } : {}),
2893
2767
  });
2894
2768
  }
2895
2769
  return redirect("/admin/setup?just_finished=1");
@@ -3383,64 +3257,6 @@ const STYLES = `
3383
3257
  .btn-secondary:hover {
3384
3258
  background: ${PALETTE.accentSoft};
3385
3259
  }
3386
- /* Copy + Show buttons ride the right edge of the MCP command pre.
3387
- Compact vertical sizing so they don't dwarf the snippet on narrow
3388
- widths; full text wrap on the pre keeps the snippet readable
3389
- behind them. The Show button toggles the visible mask on the
3390
- auto-minted Bearer token (rc.11 — discoverable
3391
- but not shoulder-surf-able). Both buttons share a small flex
3392
- container so they stack predictably on the wrap; layout-wise we
3393
- keep the right-edge padding on .mcp-cmd-wrap pre so the buttons
3394
- never overlap the command text. */
3395
- .mcp-cmd-wrap {
3396
- position: relative;
3397
- margin: 0.5rem 0;
3398
- }
3399
- .mcp-cmd-wrap pre {
3400
- background: ${PALETTE.bg};
3401
- border: 1px solid ${PALETTE.borderLight};
3402
- border-radius: 6px;
3403
- padding: 0.5rem 8.5rem 0.5rem 0.75rem;
3404
- overflow-x: auto;
3405
- font-size: 0.82rem;
3406
- margin: 0;
3407
- white-space: pre-wrap;
3408
- word-break: break-all;
3409
- }
3410
- .mcp-cmd-actions {
3411
- position: absolute;
3412
- top: 0.35rem;
3413
- right: 0.35rem;
3414
- display: flex;
3415
- gap: 0.3rem;
3416
- }
3417
- .btn-copy, .btn-mcp-aux {
3418
- padding: 0.25rem 0.6rem;
3419
- font-size: 0.78rem;
3420
- min-height: auto;
3421
- background: ${PALETTE.cardBg};
3422
- color: ${PALETTE.fg};
3423
- border: 1px solid ${PALETTE.border};
3424
- border-radius: 4px;
3425
- cursor: pointer;
3426
- font: inherit;
3427
- font-size: 0.78rem;
3428
- }
3429
- .btn-copy:hover, .btn-mcp-aux:hover {
3430
- border-color: ${PALETTE.accent};
3431
- color: ${PALETTE.accent};
3432
- }
3433
- .mcp-cmd-wrap[data-state="revealed"] pre {
3434
- /* Subtle visual cue that the token is currently visible — a warm
3435
- border so the operator notices on a screencast even at low
3436
- resolution. */
3437
- border-color: #d4a017;
3438
- background: rgba(212, 160, 23, 0.04);
3439
- }
3440
- .mcp-cmd-wrap[data-state="revealed"] .btn-mcp-aux {
3441
- border-color: #d4a017;
3442
- color: #6b4a00;
3443
- }
3444
3260
  /* Install-tile section (hub#272 Item B). Lives above the .done-grid;
3445
3261
  primary "what's next?" surface. Tiles render in a responsive grid
3446
3262
  that collapses to one column on narrow viewports. */
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Bun-native WebSocket upgrade bridge (H1, surface-runtime design §"Hub work
3
+ * items").
4
+ *
5
+ * The hub's HTTP proxy is fetch-based, and WebSocket upgrades don't traverse
6
+ * fetch — so until now the route table simply couldn't forward them (the
7
+ * hub-server docstring acknowledged it). This module is the transport half of
8
+ * the fix: when `hub-server.ts` accepts an upgrade for a service mount whose
9
+ * module DECLARES the capability (`websocket: true` on its services.json row
10
+ * or module.json — deny-by-default), it calls `server.upgrade(req, { data })`
11
+ * with a {@link WsBridgeData} payload, and these handlers pipe frames
12
+ * bidirectionally between the client socket and a Bun WebSocket client
13
+ * connected to the upstream daemon (same path, loopback).
14
+ *
15
+ * Scope discipline: this is TRANSPORT, not features —
16
+ *
17
+ * - The upstream connect carries the substrate trust headers (H2:
18
+ * X-Parachute-Layer / X-Parachute-Client-IP) plus the client's own
19
+ * headers (cookie, authorization) so the module authenticates the
20
+ * connection itself; the hub adds no WS-level auth of its own beyond the
21
+ * route gates that ran BEFORE the upgrade (publicExposure cloak,
22
+ * audience gate).
23
+ * - Subprotocol negotiation (`Sec-WebSocket-Protocol`) is NOT forwarded in
24
+ * v1 — none of the in-design consumers (y-websocket / Hocuspocus manual
25
+ * pumping) require it; forwarding it correctly needs a negotiation
26
+ * round-trip the first real consumer can motivate.
27
+ * - Backpressure is a blunt cap, not flow control: when either side's
28
+ * buffered amount exceeds {@link DEFAULT_MAX_BUFFERED_BYTES}, the bridge
29
+ * closes BOTH sides (1011). A slow consumer should reconnect rather than
30
+ * let the hub buffer unboundedly.
31
+ * - Admission control is upstream of this module (hub#649): the gate in
32
+ * `maybeUpgradeWebSocket` enforces per-client-IP + total connection caps
33
+ * (defaults 32 / 512, env-overridable via PARACHUTE_WS_MAX_PER_IP /
34
+ * PARACHUTE_WS_MAX_TOTAL — see `ws-connection-caps.ts`) BEFORE
35
+ * `server.upgrade()`. The bridge's part of the contract is release: the
36
+ * `close` handler below is the single funnel every accepted socket
37
+ * passes through, so it invokes {@link WsBridgeData.releaseCap} first
38
+ * thing — whatever the teardown reason — and the counters churn back to
39
+ * zero with the sockets.
40
+ *
41
+ * Lifecycle: either side closing tears down the other (close code + reason
42
+ * propagated where the RFC 6455 rules allow), and an upstream connect failure
43
+ * closes the client with 1011. The bridge holds no per-connection state
44
+ * outside `ws.data`, so a dropped socket leaks nothing.
45
+ */
46
+
47
+ import type { ServerWebSocket, WebSocketHandler } from "bun";
48
+
49
+ /**
50
+ * Default cap on either side's buffered (un-flushed) bytes before the bridge
51
+ * gives up and closes both sockets. 8 MiB comfortably covers CRDT sync bursts
52
+ * (a full Yjs document state vector is typically KBs) while bounding what a
53
+ * slow or stalled consumer can pin in hub memory per connection.
54
+ */
55
+ export const DEFAULT_MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
56
+
57
+ /**
58
+ * Per-connection payload attached at `server.upgrade(req, { data })` time by
59
+ * the hub's dispatch (which already ran the route gates). Everything the
60
+ * bridge needs to dial the upstream — it never re-derives routing or trust.
61
+ */
62
+ export interface WsBridgeData {
63
+ /** Absolute ws:// URL of the upstream daemon (same path + query, loopback). */
64
+ upstreamUrl: string;
65
+ /**
66
+ * Headers presented on the upstream connect: the client's own headers
67
+ * (minus hop-by-hop + WS handshake headers, which the Bun client re-mints)
68
+ * plus the H2 substrate trust stamps.
69
+ */
70
+ upstreamHeaders: Record<string, string>;
71
+ /**
72
+ * Releases this connection's slot in the connection-cap accounting
73
+ * (hub#649) — attached by the acquire site in `maybeUpgradeWebSocket`,
74
+ * invoked by the `close` handler. Self-disarming (safe to call more than
75
+ * once). Optional so handler-level unit tests that fake `ws.data` don't
76
+ * have to care about caps.
77
+ */
78
+ releaseCap?: () => void;
79
+ /** Internal bridge state — attached by `open()`, owned by this module. */
80
+ _bridge?: BridgeState;
81
+ }
82
+
83
+ interface BridgeState {
84
+ upstream: WebSocket;
85
+ /** Client frames received while the upstream is still CONNECTING. */
86
+ pending: (string | Uint8Array)[];
87
+ pendingBytes: number;
88
+ /** Set once either side initiated teardown — makes close idempotent. */
89
+ closed: boolean;
90
+ }
91
+
92
+ export interface WsBridgeOptions {
93
+ /** Override the buffered-bytes cap (tests use a tiny value). */
94
+ maxBufferedBytes?: number;
95
+ /** Test seam for the upstream WebSocket constructor. */
96
+ connectUpstream?: (url: string, headers: Record<string, string>) => WebSocket;
97
+ logger?: Pick<Console, "warn">;
98
+ }
99
+
100
+ /**
101
+ * RFC 6455: only codes 1000–4999 may be sent on the wire, and 1004/1005/1006/
102
+ * 1015 are reserved (never sent). A close event surfacing one of those (e.g.
103
+ * 1006 abnormal closure) is re-mapped to a no-code close, which the peer
104
+ * observes as 1005.
105
+ */
106
+ function sendableCloseCode(code: number | undefined): number | undefined {
107
+ if (code === undefined) return undefined;
108
+ if (code < 1000 || code > 4999) return undefined;
109
+ if (code === 1004 || code === 1005 || code === 1006 || code === 1015) return undefined;
110
+ return code;
111
+ }
112
+
113
+ /** Close reasons are capped at 123 bytes on the wire (RFC 6455 §5.5.1). */
114
+ function trimReason(reason: string | undefined): string {
115
+ if (!reason) return "";
116
+ // Trim by UTF-8 byte length, not string length.
117
+ let out = reason;
118
+ while (Buffer.byteLength(out, "utf8") > 123) out = out.slice(0, -1);
119
+ return out;
120
+ }
121
+
122
+ function closeQuietly(close: () => void): void {
123
+ try {
124
+ close();
125
+ } catch {
126
+ // Already closed / closing — teardown is best-effort by design.
127
+ }
128
+ }
129
+
130
+ /** Tear down both sides exactly once. */
131
+ function closeBoth(
132
+ ws: ServerWebSocket<WsBridgeData>,
133
+ state: BridgeState,
134
+ code: number,
135
+ reason: string,
136
+ ): void {
137
+ if (state.closed) return;
138
+ state.closed = true;
139
+ closeQuietly(() => state.upstream.close(sendableCloseCode(code), trimReason(reason)));
140
+ closeQuietly(() => ws.close(sendableCloseCode(code), trimReason(reason)));
141
+ }
142
+
143
+ function frameBytes(frame: string | Uint8Array | ArrayBuffer): number {
144
+ if (typeof frame === "string") return Buffer.byteLength(frame, "utf8");
145
+ return frame instanceof ArrayBuffer ? frame.byteLength : frame.byteLength;
146
+ }
147
+
148
+ /**
149
+ * Build the Bun.serve `websocket` handler set implementing the bridge. One
150
+ * handler object serves every bridged connection; per-connection state lives
151
+ * on `ws.data`.
152
+ */
153
+ export function createWsBridgeHandlers(opts: WsBridgeOptions = {}): WebSocketHandler<WsBridgeData> {
154
+ const cap = opts.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
155
+ const logger = opts.logger ?? console;
156
+ const connect =
157
+ opts.connectUpstream ??
158
+ ((url: string, headers: Record<string, string>) =>
159
+ // Bun's WebSocket client accepts custom headers (a Bun extension over
160
+ // the WHATWG constructor) — this is what carries the H2 trust stamps +
161
+ // the client's cookies/authorization to the upstream daemon.
162
+ new WebSocket(url, { headers } as unknown as string[]));
163
+
164
+ return {
165
+ open(ws) {
166
+ let upstream: WebSocket;
167
+ try {
168
+ upstream = connect(ws.data.upstreamUrl, ws.data.upstreamHeaders);
169
+ } catch (err) {
170
+ logger.warn(
171
+ `[ws-bridge] upstream connect threw for ${ws.data.upstreamUrl}: ${
172
+ err instanceof Error ? err.message : String(err)
173
+ }`,
174
+ );
175
+ closeQuietly(() => ws.close(1011, "upstream connect failed"));
176
+ return;
177
+ }
178
+ upstream.binaryType = "arraybuffer";
179
+ const state: BridgeState = { upstream, pending: [], pendingBytes: 0, closed: false };
180
+ ws.data._bridge = state;
181
+
182
+ upstream.addEventListener("open", () => {
183
+ if (state.closed) {
184
+ closeQuietly(() => upstream.close(1000, ""));
185
+ return;
186
+ }
187
+ for (const frame of state.pending) upstream.send(frame);
188
+ state.pending = [];
189
+ state.pendingBytes = 0;
190
+ });
191
+
192
+ upstream.addEventListener("message", (ev: MessageEvent) => {
193
+ if (state.closed) return;
194
+ const data = ev.data as string | ArrayBuffer;
195
+ ws.send(typeof data === "string" ? data : new Uint8Array(data));
196
+ // Backpressure: the client isn't draining what the upstream sends.
197
+ if (ws.getBufferedAmount() > cap) {
198
+ closeBoth(ws, state, 1011, "bridge backpressure cap exceeded");
199
+ }
200
+ });
201
+
202
+ upstream.addEventListener("close", (ev: CloseEvent) => {
203
+ if (state.closed) return;
204
+ state.closed = true;
205
+ closeQuietly(() => ws.close(sendableCloseCode(ev.code), trimReason(ev.reason)));
206
+ });
207
+
208
+ upstream.addEventListener("error", () => {
209
+ // A connect refusal (upstream not listening) surfaces here before
210
+ // any close event. Tear down the client; the close listener above is
211
+ // a no-op afterwards thanks to the `closed` latch.
212
+ closeBoth(ws, state, 1011, "upstream error");
213
+ });
214
+ },
215
+
216
+ message(ws, message) {
217
+ const state = ws.data._bridge;
218
+ if (!state || state.closed) return;
219
+ const frame: string | Uint8Array =
220
+ typeof message === "string" ? message : new Uint8Array(message);
221
+ const { upstream } = state;
222
+ if (upstream.readyState === WebSocket.OPEN) {
223
+ upstream.send(frame);
224
+ // Backpressure: the upstream isn't draining what the client sends.
225
+ if (upstream.bufferedAmount > cap) {
226
+ closeBoth(ws, state, 1011, "bridge backpressure cap exceeded");
227
+ }
228
+ } else if (upstream.readyState === WebSocket.CONNECTING) {
229
+ state.pending.push(frame);
230
+ state.pendingBytes += frameBytes(frame);
231
+ if (state.pendingBytes > cap) {
232
+ closeBoth(ws, state, 1011, "bridge backpressure cap exceeded");
233
+ }
234
+ }
235
+ // CLOSING / CLOSED: drop the frame — teardown is already in flight.
236
+ },
237
+
238
+ close(ws, code, reason) {
239
+ // Connection-cap release FIRST (hub#649) — before the bridge-state
240
+ // early-returns, because this callback fires even for sockets whose
241
+ // upstream connect threw in open() (no `_bridge` attached) and is the
242
+ // one teardown funnel every accepted socket passes through. The
243
+ // closure latches, so re-entry is harmless.
244
+ ws.data.releaseCap?.();
245
+ // Client → upstream close propagation. Note: Bun's server-side close
246
+ // callback delivers the client's close CODE but an empty `reason`
247
+ // (verified on Bun 1.3.13), so only the code propagates upstream in
248
+ // this direction. Upstream → client propagation (the close listener in
249
+ // open()) carries both.
250
+ const state = ws.data._bridge;
251
+ if (!state || state.closed) return;
252
+ state.closed = true;
253
+ closeQuietly(() => state.upstream.close(sendableCloseCode(code), trimReason(reason)));
254
+ },
255
+ };
256
+ }