@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
@@ -161,8 +161,18 @@ export interface InviteSetupProps {
161
161
  /**
162
162
  * When the invite pins a vault name the redeemer can't choose one — we show
163
163
  * it read-only. When null the redeemer names their own vault (a text field).
164
+ * With `provisionVault: false` a pinned name is a SHARED-VAULT invite: the
165
+ * redeemer is being given `role` access to the operator's existing vault.
164
166
  */
165
167
  pinnedVaultName: string | null;
168
+ /**
169
+ * When the invite pre-names the account, the username is shown read-only
170
+ * and ENFORCED server-side (the redeem handler ignores the form field).
171
+ * Null = the redeemer picks their own.
172
+ */
173
+ pinnedUsername: string | null;
174
+ /** The `user_vaults` role redemption grants ('read' | 'write') — shown on the shared-vault row. */
175
+ role: string;
166
176
  /** Whether redemption provisions a vault at all (shows the vault row iff true). */
167
177
  provisionVault: boolean;
168
178
  username?: string;
@@ -177,15 +187,55 @@ export interface InviteSetupProps {
177
187
  * same `/account/setup/<token>` path. Reuses the shared login chrome.
178
188
  */
179
189
  export function renderInviteSetup(props: InviteSetupProps): string {
180
- const { token, csrfToken, pinnedVaultName, provisionVault, username, vaultName, errorMessage } =
181
- props;
190
+ const {
191
+ token,
192
+ csrfToken,
193
+ pinnedVaultName,
194
+ pinnedUsername,
195
+ role,
196
+ provisionVault,
197
+ username,
198
+ vaultName,
199
+ errorMessage,
200
+ } = props;
182
201
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
183
202
  const usernameAttr = username ? ` value="${escapeAttr(username)}"` : "";
184
203
 
185
- // Vault row: shown only when the invite provisions a vault. Pinned → a
186
- // read-only display of the name the admin chose (redeemer can't squat names).
187
- // Unpinned a text field the redeemer fills.
204
+ // Username row: pre-named → read-only display (the server ENFORCES the
205
+ // invite's username; the disabled input never submits and the handler
206
+ // ignores the field anyway). Unpinned the normal pick-a-name field.
207
+ const usernameRow =
208
+ pinnedUsername !== null
209
+ ? `
210
+ <label class="field">
211
+ <span class="field-label">Username</span>
212
+ <input type="text" value="${escapeAttr(pinnedUsername)}" readonly disabled />
213
+ <span class="field-hint">Your hub operator chose this username for you.</span>
214
+ </label>`
215
+ : `
216
+ <label class="field">
217
+ <span class="field-label">Username</span>
218
+ <input type="text" name="username" id="username" autocomplete="username" autofocus
219
+ required minlength="2" maxlength="32"
220
+ pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
221
+ spellcheck="false" autocapitalize="off"${usernameAttr} />
222
+ <span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code></span>
223
+ </label>`;
224
+
225
+ // Vault row. Provisioning invites show the new vault's name (pinned →
226
+ // read-only; unpinned → a text field the redeemer fills). A shared-vault
227
+ // invite (no provisioning + a pinned name) shows what the redeemer is
228
+ // being given access to, including the role.
188
229
  let vaultRow = "";
230
+ if (!provisionVault && pinnedVaultName !== null) {
231
+ const roleLabel = role === "read" ? "read-only" : "read &amp; write";
232
+ vaultRow = `
233
+ <label class="field">
234
+ <span class="field-label">Shared vault</span>
235
+ <input type="text" value="${escapeAttr(pinnedVaultName)}" readonly disabled />
236
+ <span class="field-hint">You're being given ${roleLabel} access to this existing vault.</span>
237
+ </label>`;
238
+ }
189
239
  if (provisionVault) {
190
240
  if (pinnedVaultName !== null) {
191
241
  vaultRow = `
@@ -240,21 +290,20 @@ export function renderInviteSetup(props: InviteSetupProps): string {
240
290
  <div class="card-header">
241
291
  ${header()}
242
292
  <h1>Claim your invite</h1>
243
- <p class="subtitle">Pick a username and password to create your Parachute account${
244
- provisionVault ? " and your own vault" : ""
293
+ <p class="subtitle">${
294
+ pinnedUsername !== null ? "Pick a password" : "Pick a username and password"
295
+ } to create your Parachute account${
296
+ provisionVault
297
+ ? " and your own vault"
298
+ : pinnedVaultName !== null
299
+ ? " with access to a shared vault"
300
+ : ""
245
301
  }.</p>
246
302
  </div>
247
303
  ${error}
248
304
  <form method="POST" action="/account/setup/${escapeAttr(token)}" class="auth-form">
249
305
  ${renderCsrfHiddenInput(csrfToken)}
250
- <label class="field">
251
- <span class="field-label">Username</span>
252
- <input type="text" name="username" id="username" autocomplete="username" autofocus
253
- required minlength="2" maxlength="32"
254
- pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
255
- spellcheck="false" autocapitalize="off"${usernameAttr} />
256
- <span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code></span>
257
- </label>
306
+ ${usernameRow}
258
307
  <label class="field">
259
308
  <span class="field-label">Password</span>
260
309
  <input type="password" name="password" autocomplete="new-password"
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `GET /admin/module-token/<short>` — exchange a valid admin session cookie for
3
+ * a short-lived JWT carrying `<short>:admin` (audience = the bare module short).
4
+ *
5
+ * Why this exists (2026-06-09 modular-UI architecture, P3): modules now own
6
+ * their config/admin UIs and declare `configUiUrl` in `module.json` (scribe
7
+ * `/scribe/admin`, runner `/runner/admin`, surface `/surface/admin/`, …). The
8
+ * hub frames/links those surfaces consistently (the Modules page "Configure"
9
+ * action). Each module-owned config UI, served behind the hub proxy to a
10
+ * logged-in portal operator, needs an admin-scoped hub Bearer to call its own
11
+ * `<short>:admin`-gated endpoints — the same shape the channel config UI gets
12
+ * from `/admin/channel-token` and the vault admin SPA gets from
13
+ * `/admin/vault-admin-token/<name>`. This is the GENERIC mint that covers every
14
+ * other self-registered single-audience module, so the hub doesn't grow a
15
+ * bespoke per-module mint endpoint as each module ships a config UI.
16
+ *
17
+ * Scope + audience: `<short>:admin`, audience = `<short>` (the bare service
18
+ * prefix). Modules validate the JWT's `aud` against their literal short name
19
+ * (`scribe`, `runner`, `surface`, `channel`) — the same shape `inferAudience`
20
+ * stamps for the public OAuth flow, so a hub-minted and an OAuth-minted admin
21
+ * token are indistinguishable to the module. This mirrors the per-request
22
+ * `<short>:admin` proxy token `api-modules-config.ts` used to mint; the
23
+ * difference is this endpoint hands the token to the module's OWN UI rather
24
+ * than proxying a hub-side config form.
25
+ *
26
+ * Multi-vault note: VAULT is excluded here. Vault's admin scope is per-instance
27
+ * (`vault:<name>:admin`, audience `vault.<name>`), which needs a vault-name
28
+ * parameter and a known-vault check — that lives in `/admin/vault-admin-token/
29
+ * <name>` (`admin-vault-admin-token.ts`). A request for `vault` here returns a
30
+ * 400 pointing at that endpoint, so a caller can't accidentally mint a useless
31
+ * bare `vault:admin`.
32
+ *
33
+ * Gate: the session must belong to the first admin (the single hub admin under
34
+ * the Phase 1 multi-user model — `users.ts:isFirstAdmin`), exactly like
35
+ * host-admin-token / vault-admin-token / channel-token. A friend account holds
36
+ * a valid session but must not mint a module admin Bearer.
37
+ *
38
+ * Tokens are short-lived (10 min — matches the sibling admin-token mints); the
39
+ * config UI re-fetches on near-expiry.
40
+ */
41
+ import type { Database } from "bun:sqlite";
42
+ import { signAccessToken } from "./jwt-sign.ts";
43
+ import {
44
+ type ModuleManifest,
45
+ readModuleManifest as defaultReadModuleManifest,
46
+ } from "./module-manifest.ts";
47
+ import { findServiceByShort, isKnownModuleShort } from "./service-spec.ts";
48
+ import type { ServiceEntry } from "./services-manifest.ts";
49
+ import { findSession, parseSessionCookie } from "./sessions.ts";
50
+ import { isFirstAdmin } from "./users.ts";
51
+
52
+ /** Short TTL — matches host/vault/channel admin-token. UI re-fetches on near-expiry. */
53
+ export const MODULE_TOKEN_TTL_SECONDS = 10 * 60;
54
+ const MODULE_TOKEN_CLIENT_ID = "parachute-hub-spa";
55
+
56
+ /** Lowercase short-name charset, matching the module-name rule + path parsing. */
57
+ const MODULE_SHORT_RE = /^[a-z][a-z0-9-]*$/;
58
+
59
+ export interface MintModuleTokenDeps {
60
+ db: Database;
61
+ /** Hub origin — written into JWT `iss`. */
62
+ issuer: string;
63
+ /**
64
+ * Snapshot of services.json rows, read at request time so a module that
65
+ * self-registered since hub boot is mintable without a restart. Used by the
66
+ * self-registration gate (boundary C5) — see {@link isSelfRegisteredModule}.
67
+ */
68
+ readServices: () => readonly ServiceEntry[];
69
+ /** Test seam — defaults to the real `readModuleManifest` (disk read). */
70
+ readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
71
+ }
72
+
73
+ /**
74
+ * The self-registration gate (C5 of the 2026-06-09 hub-module-boundary
75
+ * migration): a short is mintable when it resolves to a services.json row
76
+ * whose `installDir` carries a readable `.parachute/module.json`.
77
+ *
78
+ * Resolution mirrors the rest of the hub (`/api/modules`,
79
+ * `collectInstalledModules`): first-party rows resolve through
80
+ * `findServiceByShort` (manifest name ↔ short map); a genuinely third-party
81
+ * row matches by its literal services.json `name` — the same
82
+ * `shortNameForManifest(name) ?? name` convention the catalog uses.
83
+ *
84
+ * Why this is enough against a forged short: services.json is written only by
85
+ * same-disk modules, which the charter's trust statement already covers — an
86
+ * installed module runs a daemon on the operator's machine, strictly more
87
+ * power than an admin Bearer. Requiring the registered row AND a readable
88
+ * manifest still keeps a typo'd short from minting `<anything>:admin` and
89
+ * masquerading as a real (but unusable) credential.
90
+ */
91
+ async function isSelfRegisteredModule(short: string, deps: MintModuleTokenDeps): Promise<boolean> {
92
+ const services = deps.readServices();
93
+ const row =
94
+ findServiceByShort(services, short) ?? services.find((s): boolean => s.name === short);
95
+ if (!row?.installDir) return false;
96
+ const readManifest = deps.readModuleManifest ?? defaultReadModuleManifest;
97
+ try {
98
+ const manifest = await readManifest(row.installDir);
99
+ return manifest !== null;
100
+ } catch {
101
+ // Malformed manifest — treat as not-a-module rather than 500ing the mint.
102
+ return false;
103
+ }
104
+ }
105
+
106
+ export async function handleModuleToken(
107
+ req: Request,
108
+ short: string,
109
+ deps: MintModuleTokenDeps,
110
+ ): Promise<Response> {
111
+ if (req.method !== "GET") {
112
+ return jsonError(405, "method_not_allowed", "use GET");
113
+ }
114
+ if (!MODULE_SHORT_RE.test(short)) {
115
+ return jsonError(400, "invalid_request", `module short "${short}" is not a valid identifier`);
116
+ }
117
+ // Vault is per-instance — its admin scope needs a vault name. Route the caller
118
+ // to the dedicated per-vault endpoint rather than minting a useless bare
119
+ // `vault:admin` (no vault validates that audience).
120
+ if (short === "vault") {
121
+ return jsonError(
122
+ 400,
123
+ "use_vault_admin_token",
124
+ "vault admin tokens are per-instance — use GET /admin/vault-admin-token/<name>",
125
+ );
126
+ }
127
+ // Only mint for modules the hub can verify exist. Two paths (boundary C5 —
128
+ // closes the charter's third-party-test gap, where this endpoint used to
129
+ // require bootstrap-registry presence):
130
+ // 1. SELF-REGISTERED: the short resolves to a services.json row whose
131
+ // installDir carries a readable module.json. This is the canonical
132
+ // gate — any module (first- or third-party) that completed
133
+ // self-registration mints with zero hub code changes, per
134
+ // `parachute-patterns/patterns/hub-module-boundary.md` (the seam,
135
+ // mechanism 1).
136
+ // 2. KNOWN bootstrap short (KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS): kept
137
+ // as a fallback so a first-party module mid-install (row not yet
138
+ // written) still mints.
139
+ // Either way a forged/typo'd short can't mint `<anything>:admin` and mask
140
+ // itself as a real (but unusable) credential.
141
+ if (!isKnownModuleShort(short) && !(await isSelfRegisteredModule(short, deps))) {
142
+ return jsonError(404, "not_found", `no module "${short}" known to this hub`);
143
+ }
144
+ const sid = parseSessionCookie(req.headers.get("cookie"));
145
+ const session = sid ? findSession(deps.db, sid) : null;
146
+ if (!session) {
147
+ return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
148
+ }
149
+ // First-admin gate (mirrors host/vault/channel-admin-token). A friend account
150
+ // (non-first-admin user) holds a valid session but must not mint a module
151
+ // admin Bearer.
152
+ if (!isFirstAdmin(deps.db, session.userId)) {
153
+ return jsonError(
154
+ 403,
155
+ "not_admin",
156
+ "module admin token mint is restricted to the hub admin — your account home is at /account/",
157
+ );
158
+ }
159
+ const scope = `${short}:admin`;
160
+ const minted = await signAccessToken(deps.db, {
161
+ sub: session.userId,
162
+ scopes: [scope],
163
+ // Bare service audience — modules validate `aud === <short>`, the same
164
+ // shape `inferAudience` stamps for the OAuth flow.
165
+ audience: short,
166
+ clientId: MODULE_TOKEN_CLIENT_ID,
167
+ issuer: deps.issuer,
168
+ ttlSeconds: MODULE_TOKEN_TTL_SECONDS,
169
+ // No per-user vault pin — this Bearer talks to a module-scoped endpoint,
170
+ // not a single vault. Empty `vault_scope` is the "no per-user restriction"
171
+ // sentinel, matching host-admin / channel tokens.
172
+ vaultScope: [],
173
+ });
174
+ return new Response(
175
+ JSON.stringify({
176
+ token: minted.token,
177
+ expires_at: minted.expiresAt,
178
+ scopes: [scope],
179
+ }),
180
+ {
181
+ status: 200,
182
+ headers: {
183
+ "content-type": "application/json",
184
+ // No browser cache — token rotates per-fetch, and a stale 200 from a
185
+ // back/forward navigation could hand the UI a long-expired JWT.
186
+ "cache-control": "no-store",
187
+ },
188
+ },
189
+ );
190
+ }
191
+
192
+ function jsonError(status: number, error: string, description: string): Response {
193
+ return new Response(JSON.stringify({ error, error_description: description }), {
194
+ status,
195
+ headers: { "content-type": "application/json" },
196
+ });
197
+ }