@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.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +310 -6
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +585 -21
- package/src/__tests__/invites.test.ts +91 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +1882 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +399 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +191 -0
- package/src/grants.ts +50 -0
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +849 -70
- package/src/invites.ts +91 -2
- package/src/jwt-sign.ts +47 -1
- package/src/module-manifest.ts +536 -23
- package/src/origin-check.ts +109 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/src/admin-login-ui.ts
CHANGED
|
@@ -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 {
|
|
181
|
-
|
|
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
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
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 & 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"
|
|
244
|
-
|
|
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
|
-
|
|
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
|
+
}
|