@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
|
@@ -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.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* admin
|
|
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
|
-
*
|
|
100
|
-
*
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
230
|
-
//
|
|
231
|
-
//
|
|
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 ?? "
|
|
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
|
+
}
|