@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.
Files changed (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -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: auto-minted operator token surfaced once on the wizard's
32
- // done screen. Single-use the done-step renderer reads + deletes the
33
- // row so a subsequent GET (page refresh, back button) doesn't re-show
34
- // the secret. Lives in hub_settings rather than tokens because it's a
35
- // wizard-flow ephemeral, not a persistent issued credential — the
36
- // mintOperatorToken call still records the jti in the `tokens`
37
- // registry, so revocation works as usual.
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. */
@@ -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. Channel ships `"channel:send"`.
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 (channel.message.deliver → channel:send).
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.