@openparachute/hub 0.7.0 → 0.7.2-rc.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/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- 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-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
package/src/hub-settings.ts
CHANGED
|
@@ -28,13 +28,14 @@ import type { Database } from "bun:sqlite";
|
|
|
28
28
|
export type HubSettingKey =
|
|
29
29
|
| "setup_expose_mode"
|
|
30
30
|
| "pending_first_client_auto_approve_until"
|
|
31
|
-
// hub#272:
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
31
|
+
// hub#272: DEPRECATED 2026-06-23 (Austen's report). Used to hold an
|
|
32
|
+
// auto-minted operator token surfaced once on the wizard's done screen
|
|
33
|
+
// so the MCP command could pre-fill a `--header "Authorization: Bearer
|
|
34
|
+
// <token>"` flag. The auto-mint was removed when vault went OAuth-
|
|
35
|
+
// default (parachute-vault #491) — nothing writes this row anymore.
|
|
36
|
+
// The key is retained so the done-step GET can defensively clear any
|
|
37
|
+
// stale row a pre-upgrade hub left behind (it never renders the value).
|
|
38
|
+
// Drop the member once no live hub_settings tables carry the row.
|
|
38
39
|
| "setup_minted_token"
|
|
39
40
|
// hub#267: the typed vault name. Persisted at vault POST time so the
|
|
40
41
|
// done step can render the operator's choice in the MCP URL +
|
|
@@ -91,7 +92,21 @@ export type HubSettingKey =
|
|
|
91
92
|
// the deprecation window. Stored as the literal string "true" /
|
|
92
93
|
// "false"; any other value parses as "redirect on" (the migration
|
|
93
94
|
// default — operators must opt out, not opt in).
|
|
94
|
-
| "notes_redirect_disabled"
|
|
95
|
+
| "notes_redirect_disabled"
|
|
96
|
+
// Admin-UI screen-lock PIN (hub admin-lock feature). The argon2id hash of
|
|
97
|
+
// the operator's lock PIN. Absent row = lock feature OFF (today's behavior
|
|
98
|
+
// exactly — the admin UI is gated only by the password-login session). When
|
|
99
|
+
// set, the admin token-mint chokepoints refuse to mint while the operator's
|
|
100
|
+
// session is "locked" (no fresh unlock, or idle-expired). NEVER plaintext;
|
|
101
|
+
// the hash sits at the same operator-local trust boundary as the password
|
|
102
|
+
// hashes + signing keys already in hub.db (see migration v11's note on
|
|
103
|
+
// at-rest encryption). The "unlocked-until" state is per-session + in-memory
|
|
104
|
+
// (admin-lock.ts) — never persisted, never in the cookie.
|
|
105
|
+
| "admin_lock_pin_hash"
|
|
106
|
+
// Idle timeout for the admin screen-lock, in seconds. Optional override of
|
|
107
|
+
// the built-in default (DEFAULT_ADMIN_LOCK_IDLE_SECONDS). Stored as a
|
|
108
|
+
// stringified integer; absent / unparseable falls back to the default.
|
|
109
|
+
| "admin_lock_idle_seconds";
|
|
95
110
|
|
|
96
111
|
export type SetupExposeMode = "localhost" | "tailnet" | "public";
|
|
97
112
|
|
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,22 @@ 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"
|
|
156
|
+
// Agent-connector grants (Phase 4b-1) — a vault token the hub mints when the
|
|
157
|
+
// operator approves an agent's `vault:<name>:<verb>` connection grant. Stored
|
|
158
|
+
// in the agent-grants store; registered here so revoke can drop it.
|
|
159
|
+
| "agent_grant";
|
|
150
160
|
|
|
151
161
|
export interface SignedRefreshToken {
|
|
152
162
|
/** Opaque token to return to the client. NOT recoverable from the DB. */
|
package/src/module-manifest.ts
CHANGED
|
@@ -124,13 +124,52 @@ export interface ModuleAction {
|
|
|
124
124
|
* Bearer` (P5). For a `vault-trigger`, this is persisted as the trigger's
|
|
125
125
|
* long-lived `action.auth.bearer` scope — the credential the sink module
|
|
126
126
|
* validates on every callback. Sourced from the action declaration so the
|
|
127
|
-
* hub never hardcodes a per-module scope.
|
|
127
|
+
* hub never hardcodes a per-module scope. Agent ships `"agent:send"`.
|
|
128
128
|
*/
|
|
129
129
|
readonly scope?: string;
|
|
130
130
|
/** Opaque (P1) descriptor of how the hub provisions this action. */
|
|
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 {
|
|
@@ -670,7 +777,7 @@ function asActions(
|
|
|
670
777
|
// `scope` is minted by the hub into a 90-day webhook bearer presented to
|
|
671
778
|
// THIS module's own endpoint, which validates `aud:<name>` + a scope in
|
|
672
779
|
// its own namespace. A legitimate `action.scope` is therefore always in
|
|
673
|
-
// the declaring module's namespace (
|
|
780
|
+
// the declaring module's namespace (agent.message.deliver → agent:send).
|
|
674
781
|
// Enforcing `<ns> === name` blocks a malicious module declaring e.g.
|
|
675
782
|
// `vault:default:admin` and tricking the hub into minting a cross-module
|
|
676
783
|
// privilege-escalating token when an operator wires a Connection to it.
|