@openparachute/hub 0.7.0 → 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.
@@ -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"
@@ -541,6 +541,12 @@ export interface DeleteVaultDeps {
541
541
  channelOrigin: string | null;
542
542
  /** Resolve a vault's loopback origin from services.json (trigger teardown). */
543
543
  resolveVaultOrigin: (vaultName: string) => string | null;
544
+ /**
545
+ * Resolve a module's loopback origin by short name (H4 — best-effort
546
+ * credential-removal notification during connection teardown). Optional:
547
+ * absent → the notification step records a warning, revocation still runs.
548
+ */
549
+ resolveModuleOrigin?: (short: string) => string | null;
544
550
  /** Test seam: run `parachute-vault remove` — same Runner seam as create. */
545
551
  runCommand?: CreateVaultDeps["runCommand"];
546
552
  /**
@@ -755,6 +761,9 @@ export async function handleDeleteVault(
755
761
  hubOrigin: deps.issuer,
756
762
  modules: [], // teardown never consults the catalog
757
763
  resolveVaultOrigin: deps.resolveVaultOrigin,
764
+ ...(deps.resolveModuleOrigin !== undefined
765
+ ? { resolveModuleOrigin: deps.resolveModuleOrigin }
766
+ : {}),
758
767
  channelOrigin: deps.channelOrigin,
759
768
  storePath: deps.connectionsStorePath,
760
769
  ...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
@@ -19,6 +19,7 @@
19
19
  import type { Database } from "bun:sqlite";
20
20
  import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
21
21
  import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
22
+ import { SERVICES_MANIFEST_PATH } from "./config.ts";
22
23
  import {
23
24
  DEFAULT_INVITE_TTL_SECONDS,
24
25
  type Invite,
@@ -28,8 +29,11 @@ import {
28
29
  issueInvite,
29
30
  listInvites,
30
31
  revokeInvite,
32
+ usernameReservedByPendingInvite,
31
33
  } from "./invites.ts";
34
+ import { getUserByUsernameCI, validateUsername } from "./users.ts";
32
35
  import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
36
+ import { listVaultNamesFromPath } from "./vault-names.ts";
33
37
 
34
38
  export interface ApiInvitesDeps {
35
39
  db: Database;
@@ -49,6 +53,7 @@ interface InviteWireShape {
49
53
  id: string;
50
54
  status: InviteStatus;
51
55
  vault_name: string | null;
56
+ username: string | null;
52
57
  role: string;
53
58
  provision_vault: boolean;
54
59
  default_mirror: string | null;
@@ -64,6 +69,7 @@ function toWire(invite: Invite, status: InviteStatus): InviteWireShape {
64
69
  id: invite.tokenHash,
65
70
  status,
66
71
  vault_name: invite.vaultName,
72
+ username: invite.username,
67
73
  role: invite.role,
68
74
  provision_vault: invite.provisionVault,
69
75
  default_mirror: invite.defaultMirror,
@@ -89,6 +95,7 @@ function redeemUrl(issuer: string, rawToken: string): string {
89
95
 
90
96
  interface CreateInviteBody {
91
97
  vaultName: string | null;
98
+ username: string | null;
92
99
  role: string;
93
100
  provisionVault: boolean;
94
101
  defaultMirror: string | null;
@@ -162,6 +169,37 @@ async function parseCreateBody(
162
169
  vaultName = rawVault;
163
170
  }
164
171
 
172
+ // username — optional. null/omitted = redeemer picks their own. Validated
173
+ // with the SAME vocabulary as /api/users (charset + length + reserved).
174
+ let username: string | null = null;
175
+ const rawUsername =
176
+ Object.hasOwn(obj, "username") && obj.username !== undefined ? obj.username : undefined;
177
+ if (rawUsername !== undefined && rawUsername !== null) {
178
+ if (typeof rawUsername !== "string" || rawUsername.length === 0) {
179
+ return {
180
+ ok: false,
181
+ status: 400,
182
+ error: "invalid_request",
183
+ description: '"username" must be a non-empty string or null',
184
+ };
185
+ }
186
+ const u = validateUsername(rawUsername);
187
+ if (!u.valid) {
188
+ return {
189
+ ok: false,
190
+ status: 400,
191
+ error: "invalid_username",
192
+ description:
193
+ u.reason === "length"
194
+ ? "username must be 2-32 characters long"
195
+ : u.reason === "reserved"
196
+ ? "username is reserved (admin, root, system, setup, parachute, hub)"
197
+ : "username must contain only lowercase letters, digits, hyphens, and underscores ([a-z0-9_-])",
198
+ };
199
+ }
200
+ username = u.name;
201
+ }
202
+
165
203
  // role — default 'write' (owner).
166
204
  let role = "write";
167
205
  const rawRole = obj.role;
@@ -245,7 +283,10 @@ async function parseCreateBody(
245
283
  expiresInSeconds = Math.floor(rawExpiry);
246
284
  }
247
285
 
248
- return { ok: true, body: { vaultName, role, provisionVault, defaultMirror, expiresInSeconds } };
286
+ return {
287
+ ok: true,
288
+ body: { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds },
289
+ };
249
290
  }
250
291
 
251
292
  /** POST /api/invites — create an invite, return the single-emit URL + token. */
@@ -262,33 +303,72 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
262
303
  }
263
304
  const parsed = await parseCreateBody(req);
264
305
  if (!parsed.ok) return jsonError(parsed.status, parsed.error, parsed.description);
265
- const { vaultName, role, provisionVault, defaultMirror, expiresInSeconds } = parsed.body;
306
+ const { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds } =
307
+ parsed.body;
308
+ const now = (deps.now ?? (() => new Date()))();
266
309
 
267
- // SECURITY: a pinned vault_name with provision_vault=false would assign the
268
- // redeeming user to a PRE-EXISTING vault as owner-admin — a cross-tenant
269
- // breach, since the owner-vs-shared role split isn't built. Shared-vault
270
- // invites aren't supported yet, so reject this combination outright (defense
271
- // in depth the redeem path rejects it too). The supported shapes are:
272
- // provision_vault=true (+ optional pinned name → provisions THAT name), or
273
- // provision_vault=false with NO name (account-only, assignedVaults=[]).
274
- if (vaultName !== null && !provisionVault) {
310
+ // Shape gates over the three supported invite shapes:
311
+ //
312
+ // 1. provision_vault=true (+ optional pinned NEW name) redemption
313
+ // provisions a fresh vault for the redeemer. Role must be 'write':
314
+ // the redeemer is the vault's ONLY user, so a read-only sole user
315
+ // would leave the new vault permanently un-writable.
316
+ // 2. provision_vault=false + vault_name SHARED-VAULT invite: redemption
317
+ // assigns the redeemer to the admin's EXISTING vault at `role` ('read'
318
+ // or 'write'). The vault must exist NOW (services.json) — pinning a
319
+ // nonexistent name is a typo, not a future reservation. Issuing is
320
+ // host:admin-gated, the same authority that can already assign any
321
+ // user to any vault via POST /api/users — the invite only packages
322
+ // that assignment as a deliverable link. The vault-delete cascade
323
+ // (`revokeInvitesForVault`) revokes pending shared invites when the
324
+ // pinned vault is deleted, and the redeem path re-checks existence.
325
+ // 3. provision_vault=false with NO name — account-only (assignedVaults=[]).
326
+ if (provisionVault && role !== "write") {
275
327
  return jsonError(
276
328
  400,
277
329
  "invalid_request",
278
- "shared-vault invites (provision_vault=false with a vault_name) aren't supported yet omit vault_name for an account-only invite, or set provision_vault=true to provision a new vault",
330
+ 'a provisioned vault\'s sole user must hold write use role "write", or share an existing vault (provision_vault=false + vault_name) for read-only access',
279
331
  );
280
332
  }
333
+ if (vaultName !== null && !provisionVault) {
334
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
335
+ const known = new Set(listVaultNamesFromPath(manifestPath));
336
+ if (!known.has(vaultName)) {
337
+ return jsonError(
338
+ 400,
339
+ "vault_not_found",
340
+ `vault "${vaultName}" is not registered on this hub — shared-vault invites must name an existing vault`,
341
+ );
342
+ }
343
+ }
344
+
345
+ // Pre-named username: catch collisions at MINT time (the redeem-time check
346
+ // stays authoritative, but an enforced name that's already taken makes the
347
+ // link dead-on-arrival — fail fast for the admin instead).
348
+ if (username !== null) {
349
+ if (getUserByUsernameCI(deps.db, username) !== null) {
350
+ return jsonError(409, "username_taken", `username "${username}" is already in use`);
351
+ }
352
+ if (usernameReservedByPendingInvite(deps.db, username, now)) {
353
+ return jsonError(
354
+ 409,
355
+ "username_reserved",
356
+ `username "${username}" is already reserved by another pending invite — revoke that invite first or pick a different name`,
357
+ );
358
+ }
359
+ }
281
360
 
282
361
  const issued = issueInvite(deps.db, {
283
362
  createdBy: authUserId,
284
363
  vaultName,
364
+ username,
285
365
  role,
286
366
  provisionVault,
287
367
  defaultMirror,
288
368
  expiresInSeconds,
289
369
  ...(deps.now !== undefined ? { now: deps.now } : {}),
290
370
  });
291
- const status = inviteStatus(issued.invite, (deps.now ?? (() => new Date()))());
371
+ const status = inviteStatus(issued.invite, now);
292
372
  return new Response(
293
373
  JSON.stringify({
294
374
  invite: toWire(issued.invite, status),
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Per-UI audience gate (H3, surface-runtime design §12 — fixes
3
+ * parachute-surface#88: the `public` flag existed but nothing enforced it).
4
+ *
5
+ * A module's services.json row may carry a `uis{}` map of hosted UI
6
+ * sub-units; each sub-unit now declares an `audience`
7
+ * (`public | hub-users | operator | surface`, default `hub-users`). The HUB
8
+ * PROXY enforces it BEFORE forwarding — surface-host serves whatever the
9
+ * proxy lets through, exactly like the publicExposure cloak.
10
+ *
11
+ * Scope discipline: the gate covers the SURFACE UI MOUNTS specifically (the
12
+ * uis sub-unit paths), not every module path — a module's own APIs keep
13
+ * their own auth (vault validates Bearers, scribe validates its token, …).
14
+ * The gate also runs before WebSocket upgrades on a gated mount (threaded
15
+ * into `maybeUpgradeWebSocket`).
16
+ *
17
+ * The four audiences:
18
+ *
19
+ * public — pass (and the chrome strip is disabled — H5: public readers
20
+ * aren't hub users).
21
+ * hub-users — a valid hub session cookie OR a valid hub-issued Bearer
22
+ * whose scopes satisfy the sub-unit's `scopes_required`. The
23
+ * OR keeps installed PWAs working: a standalone PWA holds
24
+ * OAuth tokens, not a hub session. Bearer validation reuses
25
+ * the hub#516 seam (signature/expiry/revocation via the JWKS,
26
+ * `iss` ∈ the hub's bound-origin set — a PWA token carries the
27
+ * public origin while the proxied request may resolve the
28
+ * loopback issuer).
29
+ * operator — the first-admin session only. A Bearer never satisfies this
30
+ * tier (operator surfaces are interactive; the session is the
31
+ * operator's presence).
32
+ * surface — pass: the surface backend owns admission END-TO-END
33
+ * (backed surfaces — parachute-surface runtime, surfaces with
34
+ * their own server entry, kit-authenticated via
35
+ * @openparachute/surface-server: hub JWTs / capability links /
36
+ * anon, deny-by-default with a public conformance suite). The
37
+ * hub gating these would add a SECOND auth layer that BLOCKS
38
+ * the surface's own audience plane — e.g. the docs-editor's
39
+ * capability-link invitees are NOT hub users by design, so a
40
+ * hub-users gate would 302 them to /login before the surface's
41
+ * own auth ever ran. Chrome strip follows the `public`
42
+ * precedent (disabled — the visitors are mostly capability
43
+ * invitees, not hub users), and WS upgrades pass through too
44
+ * (the gate threads into `maybeUpgradeWebSocket`; null = no
45
+ * deny = the upgrade proceeds to the capability check).
46
+ *
47
+ * Deny shape: document requests (GET + Accept: text/html, no session) get a
48
+ * 302 to `/login?next=<path>`; everything else gets 401/403 JSON. A
49
+ * signed-in-but-insufficient caller (non-admin on an operator surface, or a
50
+ * Bearer missing the required scopes) gets 403, not a login redirect.
51
+ *
52
+ * Exposure layers are ORTHOGONAL to the audience: the `publicExposure`
53
+ * cloak runs at the service-row level BEFORE this gate (dispatch skips the
54
+ * gate entirely on a cloaked row so the 404 stays indistinguishable from
55
+ * not-installed). A `surface`-audience mount on a loopback-only row is
56
+ * therefore still unreachable from tailnet/funnel — exactly like `public`.
57
+ *
58
+ * Fail-closed posture: malformed `audience` metadata never reaches this
59
+ * module — `services-manifest.ts` validation rejects the row and the lenient
60
+ * read drops it (the mount 404s). An absent DB (hub booted stateless) denies
61
+ * every audience that needs an identity store: `hub-users` and `operator`.
62
+ * `public` and `surface` still pass — neither consults hub identity
63
+ * (`surface` self-auths every request; denying it on absent DB would break
64
+ * the surface for zero security gain). Version skew: a surface declaring
65
+ * `audience: "surface"` registered against an OLDER hub (pre-dating the
66
+ * value) gets its row rejected by manifest validation — the mount 404s
67
+ * until the hub upgrades; the surface-side meta-schema ships the value
68
+ * separately with a hub-version requirement note.
69
+ */
70
+
71
+ import type { Database } from "bun:sqlite";
72
+ import { validateHostAdminToken } from "./host-admin-token-validation.ts";
73
+ import type { ServiceEntry, UiAudience, UiSubUnit } from "./services-manifest.ts";
74
+ import { findActiveSession } from "./sessions.ts";
75
+ import { isFirstAdmin } from "./users.ts";
76
+
77
+ /** A pathname resolved to the UI sub-unit that hosts it. */
78
+ export interface UiMountMatch {
79
+ readonly entry: ServiceEntry;
80
+ readonly uiKey: string;
81
+ readonly ui: UiSubUnit;
82
+ /** The normalized mount path (no trailing slash) the match keyed on. */
83
+ readonly mount: string;
84
+ /** The effective audience (default applied). */
85
+ readonly audience: UiAudience;
86
+ }
87
+
88
+ /** The effective audience for a sub-unit — absent means `hub-users`. */
89
+ export function effectiveUiAudience(ui: UiSubUnit): UiAudience {
90
+ return ui.audience ?? "hub-users";
91
+ }
92
+
93
+ /**
94
+ * Resolve which UI sub-unit (across every service's `uis{}` map) a pathname
95
+ * falls under. Longest-prefix match, same comparison shape as
96
+ * `findServiceUpstream` (trailing slashes normalized; `pathname === mount`
97
+ * or `pathname.startsWith(mount + "/")`). Returns undefined when the path is
98
+ * not under any declared UI — module API paths, undeclared mounts, and
99
+ * legacy flat rows are NOT gated here.
100
+ */
101
+ export function resolveUiMount(
102
+ services: readonly ServiceEntry[],
103
+ pathname: string,
104
+ ): UiMountMatch | undefined {
105
+ let best: UiMountMatch | undefined;
106
+ for (const entry of services) {
107
+ if (!entry.uis) continue;
108
+ for (const [uiKey, ui] of Object.entries(entry.uis)) {
109
+ const norm = ui.path.replace(/\/+$/, "") || "/";
110
+ if (pathname === norm || pathname.startsWith(`${norm}/`)) {
111
+ if (!best || norm.length > best.mount.length) {
112
+ best = { entry, uiKey, ui, mount: norm, audience: effectiveUiAudience(ui) };
113
+ }
114
+ }
115
+ }
116
+ }
117
+ return best;
118
+ }
119
+
120
+ /**
121
+ * Does one bearer scope satisfy one required-scope pattern?
122
+ *
123
+ * Patterns are colon-segmented with `*` as a single-segment wildcard —
124
+ * `vault:*:read` matches `vault:default:read`. One asymmetry is deliberate:
125
+ * the broad UNNAMED form (`vault:read`) satisfies `vault:*:<verb>` — a token
126
+ * carrying any-vault read authority is strictly wider than one pinned vault,
127
+ * so refusing it would deny a caller that holds MORE than required.
128
+ */
129
+ export function scopeMatchesPattern(pattern: string, scope: string): boolean {
130
+ const p = pattern.split(":");
131
+ const s = scope.split(":");
132
+ if (p.length === s.length) {
133
+ return p.every((seg, i) => seg === "*" || seg === s[i]);
134
+ }
135
+ // Broad unnamed form: vault:<verb> ⊇ vault:*:<verb>.
136
+ if (p.length === 3 && s.length === 2 && p[1] === "*") {
137
+ return p[0] === s[0] && p[2] === s[1];
138
+ }
139
+ return false;
140
+ }
141
+
142
+ /**
143
+ * Do the bearer's scopes satisfy the sub-unit's requirement? EVERY pattern
144
+ * in `scopes_required` must be matched by at least one bearer scope ("a
145
+ * Bearer whose scopes include the surface's scopes"). An empty/absent
146
+ * requirement means any valid hub-issued Bearer passes — the surface
147
+ * declared no scope shape, so hub identity alone is the bar.
148
+ */
149
+ export function scopesSatisfyRequirement(
150
+ required: readonly string[] | undefined,
151
+ bearerScopes: readonly string[],
152
+ ): boolean {
153
+ if (!required || required.length === 0) return true;
154
+ return required.every((pattern) => bearerScopes.some((s) => scopeMatchesPattern(pattern, s)));
155
+ }
156
+
157
+ export interface AudienceGateDeps {
158
+ /** Hub DB — absent (stateless boot) denies every hub-gated audience
159
+ * (`hub-users` / `operator`); `public` and `surface` pass without it. */
160
+ db: Database | undefined;
161
+ /**
162
+ * The hub's bound-origin set for Bearer `iss` validation (the hub#516
163
+ * seam — PWA tokens carry the public origin while the request may resolve
164
+ * loopback). Lazy: only consulted on the Bearer branch.
165
+ */
166
+ knownIssuers: () => readonly string[];
167
+ }
168
+
169
+ function wantsDocument(req: Request): boolean {
170
+ return req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html");
171
+ }
172
+
173
+ function loginRedirect(req: Request): Response {
174
+ const url = new URL(req.url);
175
+ const next = encodeURIComponent(url.pathname + url.search);
176
+ return new Response(null, {
177
+ status: 302,
178
+ headers: { location: `/login?next=${next}`, "cache-control": "no-store" },
179
+ });
180
+ }
181
+
182
+ function denyJson(status: number, error: string, description: string): Response {
183
+ return new Response(JSON.stringify({ error, error_description: description }), {
184
+ status,
185
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Enforce a sub-unit's audience for a request. Returns `null` when the
191
+ * request may proceed to the proxy, or the deny Response (302 login for
192
+ * anonymous document requests; 401/403 JSON otherwise).
193
+ */
194
+ export async function gateUiAudience(
195
+ req: Request,
196
+ audience: UiAudience,
197
+ ui: UiSubUnit,
198
+ deps: AudienceGateDeps,
199
+ ): Promise<Response | null> {
200
+ if (audience === "public") return null;
201
+
202
+ // `surface` — pass through unconditionally (incl. on an absent DB, below):
203
+ // the surface backend authenticates EVERY request itself (deny-by-default
204
+ // kit auth: hub JWTs / capability links / anon). A hub-side deny here
205
+ // would block the surface's own audience plane — its capability-link
206
+ // invitees are not hub users by design.
207
+ if (audience === "surface") return null;
208
+
209
+ // Fail closed without a DB: no identity store, no way to admit anyone to
210
+ // a hub-gated (`hub-users` / `operator`) surface.
211
+ if (!deps.db) {
212
+ return wantsDocument(req)
213
+ ? loginRedirect(req)
214
+ : denyJson(401, "unauthenticated", "this surface requires a hub identity");
215
+ }
216
+ const db = deps.db;
217
+
218
+ const session = findActiveSession(db, req);
219
+
220
+ if (audience === "operator") {
221
+ if (session && isFirstAdmin(db, session.userId)) return null;
222
+ if (session) {
223
+ return denyJson(
224
+ 403,
225
+ "not_admin",
226
+ "this surface is restricted to the hub operator — your account home is at /account/",
227
+ );
228
+ }
229
+ return wantsDocument(req)
230
+ ? loginRedirect(req)
231
+ : denyJson(401, "unauthenticated", "this surface requires the hub operator's session");
232
+ }
233
+
234
+ // audience === "hub-users"
235
+ if (session) return null;
236
+
237
+ const auth = req.headers.get("authorization");
238
+ if (auth?.startsWith("Bearer ")) {
239
+ const token = auth.slice("Bearer ".length).trim();
240
+ try {
241
+ const validated = await validateHostAdminToken(db, token, [...deps.knownIssuers()]);
242
+ const bearerScopes =
243
+ typeof validated.payload.scope === "string"
244
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
245
+ : [];
246
+ if (scopesSatisfyRequirement(ui.scopes_required, bearerScopes)) return null;
247
+ return denyJson(
248
+ 403,
249
+ "insufficient_scope",
250
+ `this surface requires scopes: ${(ui.scopes_required ?? []).join(", ")}`,
251
+ );
252
+ } catch (err) {
253
+ return denyJson(
254
+ 401,
255
+ "unauthenticated",
256
+ `bearer token invalid — ${err instanceof Error ? err.message : String(err)}`,
257
+ );
258
+ }
259
+ }
260
+
261
+ return wantsDocument(req)
262
+ ? loginRedirect(req)
263
+ : denyJson(
264
+ 401,
265
+ "unauthenticated",
266
+ "this surface requires a hub session or a hub-issued bearer token",
267
+ );
268
+ }
@@ -19,6 +19,13 @@
19
19
  * application, looks distinctively Notes, reads as Parachute because the
20
20
  * tokens are continuous").
21
21
  *
22
+ * H5 (surface-runtime design): the opt-out generalized — when a UI
23
+ * sub-unit's declared `audience` resolves `public` at the proxy's audience
24
+ * gate (H3), the dispatch passes that mount as an extra opt-out prefix
25
+ * (hub-server `decorateWithChrome`). Public readers aren't hub users; the
26
+ * identity chrome never rides their pages. The static list below remains
27
+ * for hub-users surfaces that own their own chrome (Notes).
28
+ *
22
29
  * Why path-based and not module-declared:
23
30
  * - Notes is a `uis[]` sub-unit of parachute-app, not its own module —
24
31
  * adding `chrome: "off"` to parachute-app's module.json would suppress
@@ -38,7 +45,7 @@
38
45
  * defense is cheap and protects future refactors).
39
46
  */
40
47
 
41
- import { brandMarkSvg, WORDMARK_TEXT } from "./brand.ts";
48
+ import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
42
49
  import { CSRF_FIELD_NAME, ensureCsrfToken } from "./csrf.ts";
43
50
 
44
51
  /**