@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/invites.ts
CHANGED
|
@@ -19,6 +19,24 @@
|
|
|
19
19
|
* the createUser-then-stamp ordering so a createUser failure leaves the
|
|
20
20
|
* invite re-usable.
|
|
21
21
|
*
|
|
22
|
+
* Two invite shapes carry that authorization (plus the account-only shape):
|
|
23
|
+
* - provision_vault=1 — redemption provisions a NEW vault (optionally
|
|
24
|
+
* pre-named via `vault_name`) and assigns the redeemer at `role`
|
|
25
|
+
* (always 'write': the sole user of a fresh vault must hold write).
|
|
26
|
+
* - provision_vault=0 + vault_name — a SHARED-VAULT invite: redemption
|
|
27
|
+
* assigns the redeemer to the admin's EXISTING vault at `role`
|
|
28
|
+
* ('read' or 'write'). Issuing is host:admin-gated — the same
|
|
29
|
+
* authority that can already assign any user to any vault via
|
|
30
|
+
* `POST /api/users` / `PATCH /api/users/:id/vaults` — so the invite
|
|
31
|
+
* is a delivery mechanism for an admin-authorized assignment, not an
|
|
32
|
+
* escalation. The read-only role is enforced end-to-end: every mint
|
|
33
|
+
* path caps to `vaultVerbsForRole` (users.ts) and the vault's
|
|
34
|
+
* scope-guard refuses writes for a `vault:<name>:read` token.
|
|
35
|
+
*
|
|
36
|
+
* An invite may also pre-name the redeemer's USERNAME (`username` column,
|
|
37
|
+
* v13): the redemption form shows it read-only and the redeem handler
|
|
38
|
+
* enforces it. NULL = redeemer picks their own.
|
|
39
|
+
*
|
|
22
40
|
* Single-use is enforced by stamping `used_at` on redemption — a replay
|
|
23
41
|
* attempt sees the row with `used_at` set and `redeemInvite` throws
|
|
24
42
|
* `InviteUsedError`. Revocation is a separate `revoked_at` stamp the admin
|
|
@@ -41,6 +59,11 @@ export interface Invite {
|
|
|
41
59
|
createdBy: string | null;
|
|
42
60
|
/** Pinned vault name, or null when the redeemer names their own vault. */
|
|
43
61
|
vaultName: string | null;
|
|
62
|
+
/**
|
|
63
|
+
* Pre-named username the redeemer's account gets (ENFORCED at redeem),
|
|
64
|
+
* or null when the redeemer picks their own. v13.
|
|
65
|
+
*/
|
|
66
|
+
username: string | null;
|
|
44
67
|
/** `user_vaults.role` granted on redemption (`'write'` = owner). */
|
|
45
68
|
role: string;
|
|
46
69
|
/** Whether redemption provisions a NEW vault for the redeemer. */
|
|
@@ -86,6 +109,7 @@ interface Row {
|
|
|
86
109
|
token: string;
|
|
87
110
|
created_by: string | null;
|
|
88
111
|
vault_name: string | null;
|
|
112
|
+
username: string | null;
|
|
89
113
|
role: string;
|
|
90
114
|
provision_vault: number;
|
|
91
115
|
default_mirror: string | null;
|
|
@@ -101,6 +125,7 @@ function rowToInvite(r: Row): Invite {
|
|
|
101
125
|
tokenHash: r.token,
|
|
102
126
|
createdBy: r.created_by,
|
|
103
127
|
vaultName: r.vault_name,
|
|
128
|
+
username: r.username,
|
|
104
129
|
role: r.role,
|
|
105
130
|
provisionVault: r.provision_vault === 1,
|
|
106
131
|
defaultMirror: r.default_mirror,
|
|
@@ -130,6 +155,11 @@ export interface IssueInviteOpts {
|
|
|
130
155
|
createdBy: string;
|
|
131
156
|
/** Pinned vault name; omit/null to let the redeemer name their own. */
|
|
132
157
|
vaultName?: string | null;
|
|
158
|
+
/**
|
|
159
|
+
* Pre-named username (ENFORCED at redeem); omit/null to let the redeemer
|
|
160
|
+
* pick their own. Caller validates the vocabulary + uniqueness.
|
|
161
|
+
*/
|
|
162
|
+
username?: string | null;
|
|
133
163
|
/** `user_vaults` role granted on redemption. Default `'write'` (owner). */
|
|
134
164
|
role?: string;
|
|
135
165
|
/** Provision a new vault on redemption. Default `true` (the primary flow). */
|
|
@@ -165,18 +195,20 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
|
|
|
165
195
|
const expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
|
|
166
196
|
const role = opts.role ?? "write";
|
|
167
197
|
const vaultName = opts.vaultName ?? null;
|
|
198
|
+
const username = opts.username ?? null;
|
|
168
199
|
const provisionVault = opts.provisionVault ?? true;
|
|
169
200
|
const defaultMirror = opts.defaultMirror ?? null;
|
|
170
201
|
|
|
171
202
|
db.prepare(
|
|
172
203
|
`INSERT INTO invites
|
|
173
|
-
(token, created_by, vault_name, role, provision_vault, default_mirror,
|
|
204
|
+
(token, created_by, vault_name, username, role, provision_vault, default_mirror,
|
|
174
205
|
expires_at, used_at, redeemed_user_id, revoked_at, created_at)
|
|
175
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?)`,
|
|
206
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?)`,
|
|
176
207
|
).run(
|
|
177
208
|
tokenHash,
|
|
178
209
|
opts.createdBy,
|
|
179
210
|
vaultName,
|
|
211
|
+
username,
|
|
180
212
|
role,
|
|
181
213
|
provisionVault ? 1 : 0,
|
|
182
214
|
defaultMirror,
|
|
@@ -190,6 +222,7 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
|
|
|
190
222
|
tokenHash,
|
|
191
223
|
createdBy: opts.createdBy,
|
|
192
224
|
vaultName,
|
|
225
|
+
username,
|
|
193
226
|
role,
|
|
194
227
|
provisionVault,
|
|
195
228
|
defaultMirror,
|
|
@@ -215,6 +248,40 @@ export function findInviteByHash(db: Database, tokenHash: string): Invite | null
|
|
|
215
248
|
return row ? rowToInvite(row) : null;
|
|
216
249
|
}
|
|
217
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Is `username` already reserved by a PENDING pre-named invite (unredeemed,
|
|
253
|
+
* unrevoked, not yet expired)? Two pending invites pre-naming the same
|
|
254
|
+
* username would make the second one un-redeemable (the redeem path's
|
|
255
|
+
* uniqueness check fails permanently for an enforced name), so mint-time
|
|
256
|
+
* rejects the collision.
|
|
257
|
+
*
|
|
258
|
+
* Exact `=` comparison, deliberately NOT `COLLATE NOCASE` — an asymmetry
|
|
259
|
+
* with `getUserByUsernameCI` worth naming. The users-table CI lookup is
|
|
260
|
+
* defense in depth against legacy/hand-edited `users` rows that might carry
|
|
261
|
+
* mixed case from before the validator pinned lowercase. `invites.username`
|
|
262
|
+
* has no such legacy: the column is only ever written through the
|
|
263
|
+
* `validateUsername`-gated mint path (api-invites.ts), so every stored value
|
|
264
|
+
* is already lowercase, and the value compared against it went through the
|
|
265
|
+
* same validator. A hand-edited mixed-case invites row wouldn't reserve —
|
|
266
|
+
* but it also can't redeem: the redeem path re-runs `validateUsername` on
|
|
267
|
+
* the pre-named value and rejects it (the hand-edited-row backstop in
|
|
268
|
+
* account-setup.ts).
|
|
269
|
+
*/
|
|
270
|
+
export function usernameReservedByPendingInvite(
|
|
271
|
+
db: Database,
|
|
272
|
+
username: string,
|
|
273
|
+
now: Date = new Date(),
|
|
274
|
+
): boolean {
|
|
275
|
+
const row = db
|
|
276
|
+
.query<{ token: string }, [string, string]>(
|
|
277
|
+
`SELECT token FROM invites
|
|
278
|
+
WHERE username = ? AND used_at IS NULL AND revoked_at IS NULL AND expires_at > ?
|
|
279
|
+
LIMIT 1`,
|
|
280
|
+
)
|
|
281
|
+
.get(username, now.toISOString());
|
|
282
|
+
return row !== null;
|
|
283
|
+
}
|
|
284
|
+
|
|
218
285
|
/** List every invite, newest first, with derived status. */
|
|
219
286
|
export function listInvites(
|
|
220
287
|
db: Database,
|
package/src/jwt-sign.ts
CHANGED
|
@@ -141,12 +141,18 @@ export interface SignRefreshTokenOpts {
|
|
|
141
141
|
* when provisioning a connection (the webhook bearer + the channel reply
|
|
142
142
|
* token). Registered so connection teardown can revoke them
|
|
143
143
|
* (hub-module-boundary charter, registered-mint rule).
|
|
144
|
+
*
|
|
145
|
+
* `connection_credential` — standing tag-scoped vault credentials minted by
|
|
146
|
+
* a `kind: "credential"` connection (H4, surface-runtime design). Registered
|
|
147
|
+
* for the same reason; renewal revokes the prior jti and registers the new
|
|
148
|
+
* one, so exactly one live row exists per credential connection.
|
|
144
149
|
*/
|
|
145
150
|
export type TokenCreatedVia =
|
|
146
151
|
| "oauth_refresh"
|
|
147
152
|
| "cli_mint"
|
|
148
153
|
| "operator_mint"
|
|
149
|
-
| "connection_provision"
|
|
154
|
+
| "connection_provision"
|
|
155
|
+
| "connection_credential";
|
|
150
156
|
|
|
151
157
|
export interface SignedRefreshToken {
|
|
152
158
|
/** Opaque token to return to the client. NOT recoverable from the DB. */
|
package/src/module-manifest.ts
CHANGED
|
@@ -131,6 +131,45 @@ export interface ModuleAction {
|
|
|
131
131
|
readonly provision?: unknown;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* A standing CREDENTIAL a module declares it can hold (H4, surface-runtime
|
|
136
|
+
* design / credential connections). Where an action's `scope` is a scope in
|
|
137
|
+
* the module's OWN namespace (minted for callbacks INTO the module), a
|
|
138
|
+
* credential declaration asks the hub to mint the module a standing
|
|
139
|
+
* tag-scoped token on a VAULT — operator-approved via
|
|
140
|
+
* `POST /admin/connections` with `kind: "credential"`.
|
|
141
|
+
*
|
|
142
|
+
* The `scope` field is a TEMPLATE, not a literal: `vault:{vault}:read` or
|
|
143
|
+
* `vault:{vault}:write` — the `{vault}` placeholder is filled by the
|
|
144
|
+
* operator's approval (which vault, which tags). Validation enforces the
|
|
145
|
+
* privilege-escalation guard at declaration time: ONLY the `vault` namespace,
|
|
146
|
+
* ONLY `read`/`write` verbs — never `admin`, never another module's
|
|
147
|
+
* namespace. (The POST handler re-checks the same rule, so a manifest read
|
|
148
|
+
* through a non-validating path can't smuggle a broader template.)
|
|
149
|
+
*/
|
|
150
|
+
export interface ModuleCredential {
|
|
151
|
+
/** Credential identifier within the module, e.g. `vault`. */
|
|
152
|
+
readonly key: string;
|
|
153
|
+
/** Operator-facing label. */
|
|
154
|
+
readonly title: string;
|
|
155
|
+
readonly description?: string;
|
|
156
|
+
/**
|
|
157
|
+
* Scope template: `vault:{vault}:read` | `vault:{vault}:write`. The
|
|
158
|
+
* operator approval fills `{vault}` and supplies the tag scope.
|
|
159
|
+
*/
|
|
160
|
+
readonly scope: string;
|
|
161
|
+
/**
|
|
162
|
+
* Daemon-root-relative HTTP endpoint (leading `/`) the hub POSTs the
|
|
163
|
+
* minted credential to over loopback (like the engine's channel-config
|
|
164
|
+
* delivery), authenticated with a short-lived `<module>:admin` bearer.
|
|
165
|
+
* Also receives the best-effort removal payload on teardown.
|
|
166
|
+
*/
|
|
167
|
+
readonly endpoint: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The validated shape of a credential scope template. */
|
|
171
|
+
export const CREDENTIAL_SCOPE_TEMPLATE_RE = /^vault:\{vault\}:(read|write)$/;
|
|
172
|
+
|
|
134
173
|
/**
|
|
135
174
|
* One declared parameter of a {@link ConnectionTemplate} — the operator-chosen
|
|
136
175
|
* blank in the template (e.g. WHICH vault, the channel name).
|
|
@@ -271,6 +310,16 @@ export interface ModuleManifest {
|
|
|
271
310
|
* per-module rationale.
|
|
272
311
|
*/
|
|
273
312
|
readonly stripPrefix?: boolean;
|
|
313
|
+
/**
|
|
314
|
+
* When `true`, the module's daemon accepts WebSocket upgrades and the hub's
|
|
315
|
+
* Bun-native upgrade bridge (H1, surface-runtime design) forwards
|
|
316
|
+
* `Upgrade: websocket` requests on the module's mounts. DENY BY DEFAULT:
|
|
317
|
+
* absent/false refuses upgrades (426) before they reach the daemon. The
|
|
318
|
+
* canonical capability declaration; modules also carry it onto their
|
|
319
|
+
* self-registered services.json row (`ServiceEntry.websocket`), and the hub
|
|
320
|
+
* honors either source.
|
|
321
|
+
*/
|
|
322
|
+
readonly websocket?: boolean;
|
|
274
323
|
/**
|
|
275
324
|
* Discovery tier (2026-06-09 modular-UI architecture). When a module
|
|
276
325
|
* declares `focus`, the hub's Modules screen uses it verbatim; otherwise it
|
|
@@ -298,6 +347,8 @@ export interface ModuleManifest {
|
|
|
298
347
|
readonly actions?: readonly ModuleAction[];
|
|
299
348
|
/** Connection presets this module declares — see {@link ConnectionTemplate}. */
|
|
300
349
|
readonly connectionTemplates?: readonly ConnectionTemplate[];
|
|
350
|
+
/** Standing vault credentials this module can hold — see {@link ModuleCredential} (H4). */
|
|
351
|
+
readonly credentials?: readonly ModuleCredential[];
|
|
301
352
|
}
|
|
302
353
|
|
|
303
354
|
export class ModuleManifestError extends Error {
|
|
@@ -562,6 +613,7 @@ export function validateModuleManifest(
|
|
|
562
613
|
const events = asEvents(m.events, where);
|
|
563
614
|
const actions = asActions(m.actions, where, name);
|
|
564
615
|
const connectionTemplates = asConnectionTemplates(m.connectionTemplates, where);
|
|
616
|
+
const credentials = asCredentials(m.credentials, where);
|
|
565
617
|
let stripPrefix: boolean | undefined;
|
|
566
618
|
if (m.stripPrefix !== undefined) {
|
|
567
619
|
if (typeof m.stripPrefix !== "boolean") {
|
|
@@ -569,6 +621,13 @@ export function validateModuleManifest(
|
|
|
569
621
|
}
|
|
570
622
|
stripPrefix = m.stripPrefix;
|
|
571
623
|
}
|
|
624
|
+
let websocket: boolean | undefined;
|
|
625
|
+
if (m.websocket !== undefined) {
|
|
626
|
+
if (typeof m.websocket !== "boolean") {
|
|
627
|
+
throw new ModuleManifestError(`${where}: "websocket" must be a boolean if present`);
|
|
628
|
+
}
|
|
629
|
+
websocket = m.websocket;
|
|
630
|
+
}
|
|
572
631
|
|
|
573
632
|
const out: ModuleManifest = { name, manifestName, port, paths, health };
|
|
574
633
|
if (displayName !== undefined) (out as { displayName?: string }).displayName = displayName;
|
|
@@ -590,6 +649,9 @@ export function validateModuleManifest(
|
|
|
590
649
|
if (stripPrefix !== undefined) {
|
|
591
650
|
(out as { stripPrefix?: boolean }).stripPrefix = stripPrefix;
|
|
592
651
|
}
|
|
652
|
+
if (websocket !== undefined) {
|
|
653
|
+
(out as { websocket?: boolean }).websocket = websocket;
|
|
654
|
+
}
|
|
593
655
|
if (focus !== undefined) (out as { focus?: ModuleFocus }).focus = focus;
|
|
594
656
|
if (configUiUrl !== undefined) (out as { configUiUrl?: string }).configUiUrl = configUiUrl;
|
|
595
657
|
if (adminCapabilities !== undefined) {
|
|
@@ -601,9 +663,54 @@ export function validateModuleManifest(
|
|
|
601
663
|
(out as { connectionTemplates?: readonly ConnectionTemplate[] }).connectionTemplates =
|
|
602
664
|
connectionTemplates;
|
|
603
665
|
}
|
|
666
|
+
if (credentials !== undefined) {
|
|
667
|
+
(out as { credentials?: readonly ModuleCredential[] }).credentials = credentials;
|
|
668
|
+
}
|
|
604
669
|
return out;
|
|
605
670
|
}
|
|
606
671
|
|
|
672
|
+
/**
|
|
673
|
+
* Validate the optional `credentials` declaration (H4). The scope template
|
|
674
|
+
* is the privilege-escalation guard's declaration-time half: ONLY
|
|
675
|
+
* `vault:{vault}:read` / `vault:{vault}:write` — a module can never declare
|
|
676
|
+
* its way to `admin`, to a literal vault name (the operator picks the vault
|
|
677
|
+
* at approval), or to another module's namespace. The POST handler re-checks
|
|
678
|
+
* the same rule (defense in depth for manifests read through paths that skip
|
|
679
|
+
* this validator).
|
|
680
|
+
*/
|
|
681
|
+
function asCredentials(v: unknown, where: string): readonly ModuleCredential[] | undefined {
|
|
682
|
+
if (v === undefined) return undefined;
|
|
683
|
+
if (!Array.isArray(v)) {
|
|
684
|
+
throw new ModuleManifestError(`${where}: "credentials" must be an array if present`);
|
|
685
|
+
}
|
|
686
|
+
return v.map((raw, i) => {
|
|
687
|
+
const at = `credentials[${i}]`;
|
|
688
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
689
|
+
throw new ModuleManifestError(`${where}: "${at}" must be an object`);
|
|
690
|
+
}
|
|
691
|
+
const c = raw as Record<string, unknown>;
|
|
692
|
+
const scope = asString(c.scope, where, `${at}.scope`);
|
|
693
|
+
if (!CREDENTIAL_SCOPE_TEMPLATE_RE.test(scope)) {
|
|
694
|
+
throw new ModuleManifestError(
|
|
695
|
+
`${where}: "${at}.scope" "${scope}" must be "vault:{vault}:read" or "vault:{vault}:write" — credential connections never grant admin or another namespace`,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
const endpoint = asString(c.endpoint, where, `${at}.endpoint`);
|
|
699
|
+
if (!endpoint.startsWith("/")) {
|
|
700
|
+
throw new ModuleManifestError(`${where}: "${at}.endpoint" must start with "/"`);
|
|
701
|
+
}
|
|
702
|
+
const out: ModuleCredential = {
|
|
703
|
+
key: asString(c.key, where, `${at}.key`),
|
|
704
|
+
title: asString(c.title, where, `${at}.title`),
|
|
705
|
+
scope,
|
|
706
|
+
endpoint,
|
|
707
|
+
};
|
|
708
|
+
const description = asOptionalString(c.description, where, `${at}.description`);
|
|
709
|
+
if (description !== undefined) (out as { description?: string }).description = description;
|
|
710
|
+
return out;
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
607
714
|
const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental"]);
|
|
608
715
|
|
|
609
716
|
function asFocus(v: unknown, where: string): ModuleFocus | undefined {
|
package/src/origin-check.ts
CHANGED
|
@@ -180,10 +180,13 @@ const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
|
180
180
|
* mutation under `/admin/*`; keep this list in sync with the dispatch in
|
|
181
181
|
* `hub-server.ts`):
|
|
182
182
|
*
|
|
183
|
-
* - `POST /admin/connections` + `DELETE /admin/connections/<id>`
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
183
|
+
* - `POST /admin/connections` + `DELETE /admin/connections/<id>` +
|
|
184
|
+
* `POST /admin/connections/<id>/approve`
|
|
185
|
+
* (connection provision/teardown/claim-approval — the seam's canonical
|
|
186
|
+
* consumers are channel's admin page and the hub SPA, both same-origin
|
|
187
|
+
* `fetch()` with `credentials: "include"`. The Bearer-authed
|
|
188
|
+
* `/<id>/renew` + `/<id>/claim` siblings pass the belt via the
|
|
189
|
+
* Authorization carve-out below.)
|
|
187
190
|
*
|
|
188
191
|
* (The legacy `POST/DELETE /admin/channels` pair was belted here until
|
|
189
192
|
* boundary D1 retired the endpoint — superseded by `/admin/connections`.)
|
package/src/services-manifest.ts
CHANGED
|
@@ -71,6 +71,38 @@ function normalizeUiSubUnitStatus(value: string): UiSubUnitStatus | undefined {
|
|
|
71
71
|
return UI_SUB_UNIT_STATUS_ALIASES[value];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Who may load a UI sub-unit through the hub proxy (H3, surface-runtime
|
|
76
|
+
* design §12 — fixes parachute-surface#88, where `public` was declared but
|
|
77
|
+
* never enforced):
|
|
78
|
+
*
|
|
79
|
+
* "public" — anyone; no hub identity required (chrome strip off — H5).
|
|
80
|
+
* "hub-users" — a valid hub session cookie OR a hub-issued Bearer whose
|
|
81
|
+
* scopes satisfy the surface's `scopes_required` (the OR
|
|
82
|
+
* keeps installed PWAs working). THE DEFAULT when absent.
|
|
83
|
+
* "operator" — the first-admin session only.
|
|
84
|
+
* "surface" — the surface backend owns admission END-TO-END; the hub
|
|
85
|
+
* proxy passes every request through (backed surfaces —
|
|
86
|
+
* kit-authenticated via @openparachute/surface-server: hub
|
|
87
|
+
* JWTs / capability links / anon, deny-by-default). Chrome
|
|
88
|
+
* strip off like `public` — capability-link invitees are
|
|
89
|
+
* NOT hub users.
|
|
90
|
+
*
|
|
91
|
+
* Enforced at the hub proxy BEFORE forwarding (`src/audience-gate.ts`).
|
|
92
|
+
* Legacy boolean `public` on the wire is accepted one alias window:
|
|
93
|
+
* `public: true` → `"public"`, `public: false` → default. Fail-closed: an
|
|
94
|
+
* unrecognized `audience` value rejects the row (the lenient manifest read
|
|
95
|
+
* then drops it — unroutable beats accidentally public). Version-skew
|
|
96
|
+
* corollary: a surface declaring `audience: "surface"` registered against a
|
|
97
|
+
* hub OLDER than this value's introduction gets its row rejected by that
|
|
98
|
+
* same fail-closed rule — the mount 404s until the hub is upgraded. The
|
|
99
|
+
* surface-side meta-schema ships the value with a hub-version requirement
|
|
100
|
+
* note for exactly this reason.
|
|
101
|
+
*/
|
|
102
|
+
export type UiAudience = "public" | "hub-users" | "operator" | "surface";
|
|
103
|
+
|
|
104
|
+
const UI_AUDIENCE_VALUES: readonly UiAudience[] = ["public", "hub-users", "operator", "surface"];
|
|
105
|
+
|
|
74
106
|
/**
|
|
75
107
|
* A sub-unit beneath a module — used today by parachute-app to surface each
|
|
76
108
|
* hosted UI as its own discoverable row under the App module, and the shape
|
|
@@ -129,6 +161,20 @@ export interface UiSubUnit {
|
|
|
129
161
|
oauthClientId?: string;
|
|
130
162
|
/** UI sub-unit lifecycle state. Absent → discovery treats as "active". */
|
|
131
163
|
status?: UiSubUnitStatus;
|
|
164
|
+
/**
|
|
165
|
+
* Audience exposure (H3). Absent → "hub-users". The legacy boolean
|
|
166
|
+
* `public` field is normalized into this on read (true → "public") for
|
|
167
|
+
* one alias window. See {@link UiAudience}.
|
|
168
|
+
*/
|
|
169
|
+
audience?: UiAudience;
|
|
170
|
+
/**
|
|
171
|
+
* OAuth scopes the UI declares as required (surface-host stamps these from
|
|
172
|
+
* the surface's meta.json `scopes_required`, e.g. `["vault:*:read"]` —
|
|
173
|
+
* `*` is a single-segment wildcard). The audience gate's Bearer branch
|
|
174
|
+
* checks a presented token's scopes against these. Snake_case to match
|
|
175
|
+
* the wire shape surface already writes.
|
|
176
|
+
*/
|
|
177
|
+
scopes_required?: string[];
|
|
132
178
|
}
|
|
133
179
|
|
|
134
180
|
export interface ServiceEntry {
|
|
@@ -170,6 +216,16 @@ export interface ServiceEntry {
|
|
|
170
216
|
* bridges the gap. Tracked in parachute-scribe (separate issue).
|
|
171
217
|
*/
|
|
172
218
|
stripPrefix?: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* When `true`, the module's daemon accepts WebSocket upgrades and the hub's
|
|
221
|
+
* Bun-native upgrade bridge (H1, surface-runtime design) will forward
|
|
222
|
+
* `Upgrade: websocket` requests on this module's mounts to it. DENY BY
|
|
223
|
+
* DEFAULT: absent/false means an upgrade request to this mount is refused
|
|
224
|
+
* (426) without ever reaching the daemon. Modules declare this on their
|
|
225
|
+
* `.parachute/module.json` (the install-time contract) and carry it onto
|
|
226
|
+
* their self-registered services.json row; the hub honors either source.
|
|
227
|
+
*/
|
|
228
|
+
websocket?: boolean;
|
|
173
229
|
/**
|
|
174
230
|
* Sub-units hosted under this module — parachute-app's bag of UIs, and
|
|
175
231
|
* the shape vault is expected to adopt for per-instance metadata in a
|
|
@@ -282,6 +338,10 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
|
|
|
282
338
|
if (stripPrefix !== undefined && typeof stripPrefix !== "boolean") {
|
|
283
339
|
throw new ServicesManifestError(`${where}: "stripPrefix" must be a boolean if present`);
|
|
284
340
|
}
|
|
341
|
+
const websocket = e.websocket;
|
|
342
|
+
if (websocket !== undefined && typeof websocket !== "boolean") {
|
|
343
|
+
throw new ServicesManifestError(`${where}: "websocket" must be a boolean if present`);
|
|
344
|
+
}
|
|
285
345
|
const uis = e.uis;
|
|
286
346
|
const validatedUis = validateUis(uis, where);
|
|
287
347
|
const validatedStartError = validateStartError(e.lastStartError, where);
|
|
@@ -291,6 +351,7 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
|
|
|
291
351
|
if (publicExposure !== undefined) entry.publicExposure = publicExposure as PublicExposure;
|
|
292
352
|
if (installDir !== undefined) entry.installDir = installDir;
|
|
293
353
|
if (stripPrefix !== undefined) entry.stripPrefix = stripPrefix;
|
|
354
|
+
if (websocket !== undefined) entry.websocket = websocket;
|
|
294
355
|
if (validatedUis !== undefined) entry.uis = validatedUis;
|
|
295
356
|
if (validatedStartError !== undefined) entry.lastStartError = validatedStartError;
|
|
296
357
|
return entry;
|
|
@@ -405,12 +466,48 @@ function validateUiSubUnit(raw: unknown, where: string): UiSubUnit {
|
|
|
405
466
|
}
|
|
406
467
|
normalizedStatus = norm;
|
|
407
468
|
}
|
|
469
|
+
// Audience (H3). Explicit `audience` wins; the legacy boolean `public` is
|
|
470
|
+
// accepted as an alias for one release window (true → "public"; false →
|
|
471
|
+
// the default, i.e. no field). FAIL-CLOSED on malformed values: throwing
|
|
472
|
+
// here makes the lenient manifest read drop the whole row — an unroutable
|
|
473
|
+
// surface beats one that silently falls open to the wrong audience.
|
|
474
|
+
let audience: UiAudience | undefined;
|
|
475
|
+
if (u.audience !== undefined) {
|
|
476
|
+
if (
|
|
477
|
+
typeof u.audience !== "string" ||
|
|
478
|
+
!(UI_AUDIENCE_VALUES as readonly string[]).includes(u.audience)
|
|
479
|
+
) {
|
|
480
|
+
throw new ServicesManifestError(
|
|
481
|
+
`${where}: "audience" must be "public" | "hub-users" | "operator" | "surface" if present (got ${JSON.stringify(u.audience)})`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
audience = u.audience as UiAudience;
|
|
485
|
+
} else if (u.public !== undefined) {
|
|
486
|
+
if (typeof u.public !== "boolean") {
|
|
487
|
+
throw new ServicesManifestError(`${where}: legacy "public" must be a boolean if present`);
|
|
488
|
+
}
|
|
489
|
+
if (u.public) audience = "public";
|
|
490
|
+
}
|
|
491
|
+
let scopesRequired: string[] | undefined;
|
|
492
|
+
if (u.scopes_required !== undefined) {
|
|
493
|
+
if (
|
|
494
|
+
!Array.isArray(u.scopes_required) ||
|
|
495
|
+
u.scopes_required.some((s) => typeof s !== "string" || s.length === 0)
|
|
496
|
+
) {
|
|
497
|
+
throw new ServicesManifestError(
|
|
498
|
+
`${where}: "scopes_required" must be an array of non-empty strings if present`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
scopesRequired = u.scopes_required as string[];
|
|
502
|
+
}
|
|
408
503
|
const out: UiSubUnit = { displayName, path };
|
|
409
504
|
if (tagline !== undefined) out.tagline = tagline;
|
|
410
505
|
if (iconUrl !== undefined) out.iconUrl = iconUrl;
|
|
411
506
|
if (version !== undefined) out.version = version;
|
|
412
507
|
if (oauthClientId !== undefined) out.oauthClientId = oauthClientId;
|
|
413
508
|
if (normalizedStatus !== undefined) out.status = normalizedStatus;
|
|
509
|
+
if (audience !== undefined) out.audience = audience;
|
|
510
|
+
if (scopesRequired !== undefined) out.scopes_required = scopesRequired;
|
|
414
511
|
return out;
|
|
415
512
|
}
|
|
416
513
|
|