@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.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- 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 +266 -0
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- 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/admin-connections.ts +916 -14
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-vaults.ts +9 -0
- package/src/api-invites.ts +92 -12
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/connections-store.ts +32 -2
- 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 +501 -12
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +7 -1
- package/src/module-manifest.ts +107 -0
- package/src/origin-check.ts +7 -4
- package/src/services-manifest.ts +97 -0
- 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/index.html +1 -1
- package/web/ui/dist/assets/index-C-XzMVqN.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"
|
package/src/admin-vaults.ts
CHANGED
|
@@ -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 } : {}),
|
package/src/api-invites.ts
CHANGED
|
@@ -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 {
|
|
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 } =
|
|
306
|
+
const { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds } =
|
|
307
|
+
parsed.body;
|
|
308
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
266
309
|
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
// provision_vault=false
|
|
274
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|
package/src/chrome-strip.ts
CHANGED
|
@@ -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 {
|
|
48
|
+
import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
|
|
42
49
|
import { CSRF_FIELD_NAME, ensureCsrfToken } from "./csrf.ts";
|
|
43
50
|
|
|
44
51
|
/**
|