@openparachute/hub 0.7.1 → 0.7.2

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 (73) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  4. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  5. package/src/__tests__/admin-connections-credentials.test.ts +8 -4
  6. package/src/__tests__/admin-connections.test.ts +211 -57
  7. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  8. package/src/__tests__/admin-lock.test.ts +600 -0
  9. package/src/__tests__/admin-module-token.test.ts +36 -8
  10. package/src/__tests__/admin-vaults.test.ts +8 -8
  11. package/src/__tests__/api-modules-ops.test.ts +17 -16
  12. package/src/__tests__/api-modules.test.ts +35 -36
  13. package/src/__tests__/api-ready.test.ts +2 -2
  14. package/src/__tests__/clients.test.ts +91 -0
  15. package/src/__tests__/grants-store.test.ts +219 -0
  16. package/src/__tests__/hub-server.test.ts +9 -5
  17. package/src/__tests__/migrate.test.ts +1 -1
  18. package/src/__tests__/module-manifest.test.ts +11 -11
  19. package/src/__tests__/oauth-client.test.ts +446 -0
  20. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  21. package/src/__tests__/oauth-handlers.test.ts +124 -26
  22. package/src/__tests__/operator-token.test.ts +2 -2
  23. package/src/__tests__/scope-explanations.test.ts +3 -3
  24. package/src/__tests__/serve-boot.test.ts +14 -14
  25. package/src/__tests__/serve.test.ts +26 -0
  26. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  27. package/src/__tests__/services-manifest.test.ts +60 -48
  28. package/src/__tests__/setup-gate.test.ts +52 -3
  29. package/src/__tests__/setup-wizard.test.ts +86 -280
  30. package/src/__tests__/setup.test.ts +1 -1
  31. package/src/__tests__/upgrade.test.ts +276 -0
  32. package/src/__tests__/vault-remove.test.ts +393 -0
  33. package/src/admin-agent-grants.ts +1365 -0
  34. package/src/admin-agent-token.ts +147 -0
  35. package/src/admin-connections.ts +67 -50
  36. package/src/admin-host-admin-token.ts +14 -1
  37. package/src/admin-lock.ts +281 -0
  38. package/src/admin-module-token.ts +15 -7
  39. package/src/admin-vault-admin-token.ts +8 -1
  40. package/src/admin-vaults.ts +12 -12
  41. package/src/api-admin-lock.ts +335 -0
  42. package/src/api-modules-ops.ts +3 -2
  43. package/src/api-modules.ts +9 -9
  44. package/src/cli.ts +13 -1
  45. package/src/clients.ts +88 -0
  46. package/src/commands/install.ts +7 -0
  47. package/src/commands/serve-boot.ts +5 -4
  48. package/src/commands/serve.ts +45 -19
  49. package/src/commands/setup.ts +4 -3
  50. package/src/commands/upgrade.ts +118 -2
  51. package/src/commands/vault-remove.ts +361 -0
  52. package/src/commands/wizard.ts +4 -4
  53. package/src/connections-store.ts +3 -3
  54. package/src/grants-store.ts +272 -0
  55. package/src/help.ts +4 -1
  56. package/src/hub-server.ts +209 -27
  57. package/src/hub-settings.ts +23 -8
  58. package/src/jwt-sign.ts +5 -1
  59. package/src/module-manifest.ts +2 -2
  60. package/src/oauth-client.ts +497 -0
  61. package/src/oauth-flows-store.ts +163 -0
  62. package/src/oauth-handlers.ts +40 -13
  63. package/src/operator-token.ts +1 -1
  64. package/src/origin-check.ts +7 -2
  65. package/src/resource-binding.ts +4 -4
  66. package/src/scope-explanations.ts +3 -3
  67. package/src/service-spec.ts +56 -43
  68. package/src/setup-wizard.ts +56 -240
  69. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  70. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/admin-channel-token.ts +0 -135
  73. package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `GET /admin/agent-token` — exchange a valid admin session cookie for a
3
+ * short-lived JWT carrying `agent:read agent:send agent:admin`.
4
+ *
5
+ * Renamed from `/admin/channel-token` 2026-06-17 (parachute-channel →
6
+ * parachute-agent). The old path keeps working via a 301 redirect in
7
+ * hub-server.ts for one release cycle.
8
+ *
9
+ * Why this exists: two agent-owned UIs, both served behind hub's proxy to a
10
+ * logged-in portal operator, need a Bearer to talk to agent's API the same
11
+ * way the vault-management and scribe-config SPAs do, without running the
12
+ * public `/oauth/authorize` flow:
13
+ * - The **chat UI** (`/agent/ui`) receives replies over SSE
14
+ * (`agent:read`) and posts a message (`agent:send`).
15
+ * - The **config/admin UI** (`/agent/admin`, the 2026-06-09 modular-UI
16
+ * architecture P3/P4 config surface) lists + edits configured channels via
17
+ * `agent:admin`-gated endpoints (`requireScope(SCOPE_ADMIN)` in agent's
18
+ * daemon).
19
+ *
20
+ * Both UIs fetch this single endpoint (`fetchToken()` against
21
+ * `/admin/agent-token`), so the minted token carries the union of the scopes
22
+ * either UI needs. The chat UI simply ignores the extra `agent:admin` scope;
23
+ * `requireScope` checks for the *presence* of a specific scope, so extra
24
+ * scopes never break a read/send call. This is what makes the agent config
25
+ * UI work without re-touching the agent repo — the hub endpoint the config
26
+ * UI already calls now mints the admin scope it needs (2026-06-09 modular-UI
27
+ * architecture, P3).
28
+ *
29
+ * Scope choice — `agent:read agent:send agent:admin`, deliberately NOT
30
+ * `agent:write`:
31
+ * - `agent:read` — receive replies over SSE.
32
+ * - `agent:send` — post a message into the channel.
33
+ * - `agent:admin` — list + edit channel config (the config UI).
34
+ * - `agent:write` is the *session-reply* scope (a connected Claude Code
35
+ * session replying on a channel). A UI token must not be able to
36
+ * impersonate a session, so we never mint `agent:write` here.
37
+ *
38
+ * Audience: `agent` (the bare service prefix). Agent validates the JWT's
39
+ * `aud` claim against the literal string `"agent"` (parachute-agent
40
+ * `src/hub-jwt.ts`), the same shape `inferAudience` in oauth-handlers.ts
41
+ * stamps for the public OAuth flow — so hub-minted and OAuth-minted agent
42
+ * tokens are indistinguishable to agent. Unlike the per-vault admin token
43
+ * (`vault.<name>`), agent has a single bare audience. (During the rename
44
+ * transition window the agent daemon dual-accepts both `aud: "channel"` and
45
+ * `aud: "agent"`, so pre-rename tokens keep validating until re-minted — see
46
+ * the channel→agent migration doc; that back-compat lives in the daemon, not
47
+ * here.)
48
+ *
49
+ * Multi-user Phase 1 gate: the session must belong to the first admin (the
50
+ * single hub admin under the Phase 1 model — see `users.ts:isFirstAdmin`),
51
+ * mirroring host-admin-token and vault-admin-token. Friends pinned to a vault
52
+ * use the OAuth flow for their assigned scopes; they don't get an agent
53
+ * Bearer via this endpoint.
54
+ *
55
+ * Tokens minted here are short-lived (10 min — matches host/vault admin
56
+ * tokens); the UI re-fetches on near-expiry.
57
+ */
58
+ import type { Database } from "bun:sqlite";
59
+ import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
60
+ import { signAccessToken } from "./jwt-sign.ts";
61
+ import { findSession, parseSessionCookie } from "./sessions.ts";
62
+ import { isFirstAdmin } from "./users.ts";
63
+
64
+ /** Short TTL — matches host/vault admin-token. UI re-fetches on near-expiry. */
65
+ export const AGENT_TOKEN_TTL_SECONDS = 10 * 60;
66
+ const AGENT_AUDIENCE = "agent";
67
+ const AGENT_CLIENT_ID = "parachute-hub-spa";
68
+ /**
69
+ * `agent:read` (SSE replies) + `agent:send` (post a message) +
70
+ * `agent:admin` (list + edit channel config — the config UI). Deliberately
71
+ * NOT `agent:write` — that's the session-reply scope, and a UI token must
72
+ * not be able to impersonate a connected session. The chat UI ignores the
73
+ * extra `agent:admin`; the config UI needs it (2026-06-09 modular-UI
74
+ * architecture, P3 — the hub endpoint the agent config UI already calls
75
+ * mints the admin scope so the agent repo doesn't have to change).
76
+ */
77
+ export const AGENT_TOKEN_SCOPES = ["agent:read", "agent:send", "agent:admin"] as const;
78
+
79
+ export interface MintAgentTokenDeps {
80
+ db: Database;
81
+ /** Hub origin — written into JWT `iss`. */
82
+ issuer: string;
83
+ }
84
+
85
+ export async function handleAgentToken(req: Request, deps: MintAgentTokenDeps): Promise<Response> {
86
+ if (req.method !== "GET") {
87
+ return jsonError(405, "method_not_allowed", "use GET");
88
+ }
89
+ const sid = parseSessionCookie(req.headers.get("cookie"));
90
+ const session = sid ? findSession(deps.db, sid) : null;
91
+ if (!session || !sid) {
92
+ return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
93
+ }
94
+ // First-admin gate (mirrors host/vault-admin-token). A friend account
95
+ // (non-first-admin user created via `/api/users`) holds a valid session but
96
+ // must not mint an agent Bearer. Without this check, any signed-in friend
97
+ // hitting `GET /admin/agent-token` would walk away with a token carrying
98
+ // `agent:read agent:send`.
99
+ if (!isFirstAdmin(deps.db, session.userId)) {
100
+ return jsonError(
101
+ 403,
102
+ "not_admin",
103
+ "agent token mint is restricted to the hub admin — your account home is at /account/",
104
+ );
105
+ }
106
+ // Admin screen-lock gate (see admin-host-admin-token.ts). A locked admin
107
+ // session can't mint an agent Bearer, so agent's config + chat UIs show
108
+ // the lock screen / fail closed until the operator unlocks. Off by default.
109
+ if (!requireUnlocked(deps.db, sid).ok) {
110
+ return lockedResponse();
111
+ }
112
+ const minted = await signAccessToken(deps.db, {
113
+ sub: session.userId,
114
+ scopes: [...AGENT_TOKEN_SCOPES],
115
+ audience: AGENT_AUDIENCE,
116
+ clientId: AGENT_CLIENT_ID,
117
+ issuer: deps.issuer,
118
+ ttlSeconds: AGENT_TOKEN_TTL_SECONDS,
119
+ // Agent tokens carry no per-user vault pin — the UI Bearer talks to an
120
+ // agent-scoped endpoint, not to a single vault. Empty `vault_scope` is
121
+ // the "no per-user restriction" sentinel matching host-admin tokens.
122
+ vaultScope: [],
123
+ });
124
+ return new Response(
125
+ JSON.stringify({
126
+ token: minted.token,
127
+ expires_at: minted.expiresAt,
128
+ scopes: AGENT_TOKEN_SCOPES,
129
+ }),
130
+ {
131
+ status: 200,
132
+ headers: {
133
+ "content-type": "application/json",
134
+ // No browser cache — token rotates per-fetch, and a stale 200 from a
135
+ // back/forward navigation could hand the UI a long-expired JWT.
136
+ "cache-control": "no-store",
137
+ },
138
+ },
139
+ );
140
+ }
141
+
142
+ function jsonError(status: number, error: string, description: string): Response {
143
+ return new Response(JSON.stringify({ error, error_description: description }), {
144
+ status,
145
+ headers: { "content-type": "application/json" },
146
+ });
147
+ }
@@ -4,7 +4,7 @@
4
4
  * architecture, P5). Generalizes the channel-specific `/admin/channels`
5
5
  * endpoint (hub#624 era; retired in boundary D1): "add a vault-backed channel"
6
6
  * is just the first connection, `vault.note.created (filter
7
- * #channel-message/inbound) → channel.message.deliver`.
7
+ * #agent-message/inbound) → agent.message.deliver`.
8
8
  *
9
9
  * THE CONCEPT. A connection wires "when [EVENT] in [source module] (filter) →
10
10
  * do [ACTION] in [sink module]". The sink is ALWAYS an action. Modules declare
@@ -17,7 +17,7 @@
17
17
  * - the SINK action's `endpoint` → the hub-proxied webhook the vault calls
18
18
  * (`<hub-origin>/<sink-mount><endpoint>`). NOT a hardcoded channel path.
19
19
  * - the SINK action's `scope` → the OAuth scope minted into the webhook's
20
- * `Authorization: Bearer`. NOT a hardcoded `channel:send`.
20
+ * `Authorization: Bearer`. NOT a hardcoded `agent:send`.
21
21
  * - the SOURCE event key → the vault trigger's `events` (`note.created` →
22
22
  * `["created"]`, `note.updated` → `["updated"]`).
23
23
  * - the SOURCE filter → the vault trigger's `when` predicate (`tags` /
@@ -27,9 +27,9 @@
27
27
  *
28
28
  * THE ONE SINK-SPECIFIC PREREQUISITE. A vault-backed channel additionally needs
29
29
  * its reply path wired: a `vault:<v>:write` token + a `channels.json` entry so
30
- * the session can reply. That's a property of the channel SINK, not of the
31
- * engine — it runs only for `sink.module === "channel"` and is clearly fenced
32
- * (`prepareChannelSink`). Everything else is declaration-driven.
30
+ * the session can reply. That's a property of the agent SINK, not of the
31
+ * engine — it runs only for `sink.module === "agent"` and is clearly fenced
32
+ * (`prepareAgentSink`). Everything else is declaration-driven.
33
33
  *
34
34
  * AUTH. Same gate as the admin-token mints: a cookie-gated operator session
35
35
  * pinned to the first admin. The catalog (`/api/connections/catalog`) is
@@ -137,7 +137,7 @@ export interface InstalledModuleInfo {
137
137
  /** Parsed `.parachute/module.json`. */
138
138
  readonly manifest: ModuleManifest;
139
139
  /**
140
- * The module's user-facing mount path under the hub origin (e.g. `/channel`),
140
+ * The module's user-facing mount path under the hub origin (e.g. `/agent`),
141
141
  * used to build a hub-proxied webhook from a sink action's `endpoint`.
142
142
  * `null` when the module declares no user-facing mount.
143
143
  */
@@ -169,8 +169,8 @@ export interface ConnectionsDeps {
169
169
  * teardown logs the skipped notification.
170
170
  */
171
171
  resolveModuleOrigin?: (short: string) => string | null;
172
- /** Loopback origin for the channel daemon, or `null` when not installed. */
173
- channelOrigin: string | null;
172
+ /** Loopback origin for the agent daemon, or `null` when not installed. */
173
+ agentOrigin: string | null;
174
174
  /** Absolute path to `connections.json` in the hub state dir. */
175
175
  storePath: string;
176
176
  /** Test seam — `globalThis.fetch` in production. */
@@ -452,7 +452,7 @@ interface CreateBody {
452
452
  /**
453
453
  * Provenance — WHO requested this connection (modular-UI R2). A module-owned
454
454
  * config UI calling this endpoint on the operator's behalf labels itself (e.g.
455
- * `"channel"`); the hub's own builder omits it and falls back to `"custom"`.
455
+ * `"agent"`); the hub's own builder omits it and falls back to `"custom"`.
456
456
  */
457
457
  requestedBy?: unknown;
458
458
  }
@@ -601,20 +601,28 @@ async function createConnection(
601
601
  // the record so teardown can revoke them (registered-mint rule).
602
602
  const mintedJtis: string[] = [];
603
603
 
604
- // --- Sink prerequisite (channel reply path), fenced to the channel sink. --
604
+ // --- Sink prerequisite (agent message-delivery reply path). --------------
605
605
  // Everything below this is general; THIS block is the only sink-specific step.
606
- // The channel name comes from the action params (`sink.params.channel`) — it
607
- // becomes a services.json key + an MCP server name, so it must be a slug.
608
- if (sinkModule === "channel") {
606
+ // It is gated on the ACTION, not just the module: ONLY `message.deliver` needs
607
+ // a session reply path (a vault-backed channel's connected session replies via
608
+ // a `vault:<v>:write` token + a `channels.json` entry). Other agent actions —
609
+ // e.g. `definition.reload`, a pure inbound webhook with no session and no
610
+ // reply — need none of it. A module-level gate here 400'd every
611
+ // non-`message.deliver` agent sink for want of a `channel` param (agent#117:
612
+ // the def-reload connectors could never provision). The channel name comes
613
+ // from `sink.params.channel` — it becomes a services.json key + an MCP server
614
+ // name, so it must be a slug. (The session-channel concept is kept; only the
615
+ // MODULE renamed channel → agent in the 2026-06-17 rename.)
616
+ if (sinkModule === "agent" && sinkAction === "message.deliver") {
609
617
  const channelName = typeof sinkParams?.channel === "string" ? sinkParams.channel : "";
610
618
  if (!CHANNEL_NAME_RE.test(channelName)) {
611
619
  return jsonError(
612
620
  400,
613
621
  "invalid_request",
614
- `channel sink requires sink.params.channel as a valid identifier; got "${channelName}"`,
622
+ `agent sink requires sink.params.channel as a valid identifier; got "${channelName}"`,
615
623
  );
616
624
  }
617
- const prep = await prepareChannelSink(channelName, vault, vaultOrigin, userId, deps);
625
+ const prep = await prepareAgentSink(channelName, vault, vaultOrigin, userId, deps);
618
626
  if (prep.error) return prep.error;
619
627
  mintedJtis.push(prep.replyTokenJti);
620
628
  }
@@ -681,35 +689,33 @@ async function createConnection(
681
689
  connection: typeof record;
682
690
  connect?: { mcpAdd: string; launch: string };
683
691
  } = { ok: true, connection: record };
684
- if (sinkModule === "channel" && typeof sinkParams?.channel === "string") {
692
+ if (sinkModule === "agent" && typeof sinkParams?.channel === "string") {
685
693
  out.connect = channelConnectLines(deps.hubOrigin, sinkParams.channel);
686
694
  }
687
695
  return json(200, out);
688
696
  }
689
697
 
690
698
  /**
691
- * The channel sink's reply-path prerequisite (mirrors hub#624). Mints a
699
+ * The agent sink's reply-path prerequisite (mirrors hub#624). Mints a
692
700
  * `vault:<v>:write` for the channel + writes the `channels.json` entry on the
693
- * channel daemon so the session can reply. Fenced to `sink.module === "channel"`
694
- * this is sink-specific config, not part of the general vault-trigger engine.
701
+ * agent daemon so the session can reply. Fenced to the agent `message.deliver`
702
+ * action (the only one with a reply path) — sink-specific config, not part of
703
+ * the general vault-trigger engine.
695
704
  * Returns `{ error }` on failure, or `{ error: null, replyTokenJti }` on
696
705
  * success — the jti of the long-lived reply token, so the caller can persist
697
- * it for teardown revocation.
706
+ * it for teardown revocation. (Renamed from `prepareChannelSink` 2026-06-17;
707
+ * the agent daemon's session-channel CRUD is still `/api/channels`.)
698
708
  */
699
- async function prepareChannelSink(
709
+ async function prepareAgentSink(
700
710
  channelName: string,
701
711
  vault: string,
702
712
  vaultOrigin: string,
703
713
  userId: string,
704
714
  deps: ConnectionsDeps,
705
715
  ): Promise<{ error: Response } | { error: null; replyTokenJti: string }> {
706
- if (deps.channelOrigin === null) {
716
+ if (deps.agentOrigin === null) {
707
717
  return {
708
- error: jsonError(
709
- 503,
710
- "channel_unavailable",
711
- "the channel module is not installed on this hub",
712
- ),
718
+ error: jsonError(503, "agent_unavailable", "the agent module is not installed on this hub"),
713
719
  };
714
720
  }
715
721
  const fetchImpl = deps.fetchImpl ?? fetch;
@@ -718,17 +724,17 @@ async function prepareChannelSink(
718
724
  scopes: [`vault:${vault}:write`],
719
725
  audience: `vault.${vault}`,
720
726
  vaultScope: [vault],
721
- ttlSeconds: WEBHOOK_BEARER_TTL_SECONDS, // channel keeps it for its lifetime
727
+ ttlSeconds: WEBHOOK_BEARER_TTL_SECONDS, // agent keeps it for its lifetime
722
728
  });
723
729
  const channelAdminToken = (
724
730
  await mint(deps, userId, {
725
- scopes: ["channel:admin"],
726
- audience: "channel",
731
+ scopes: ["agent:admin"],
732
+ audience: "agent",
727
733
  vaultScope: [],
728
734
  ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
729
735
  })
730
736
  ).token;
731
- const res = await fetchImpl(`${deps.channelOrigin}/api/channels`, {
737
+ const res = await fetchImpl(`${deps.agentOrigin}/api/channels`, {
732
738
  method: "POST",
733
739
  headers: {
734
740
  authorization: `Bearer ${channelAdminToken}`,
@@ -740,10 +746,10 @@ async function prepareChannelSink(
740
746
  config: { vault, vaultUrl: vaultOrigin, token: vaultWriteSigned.token },
741
747
  }),
742
748
  });
743
- if (!res.ok) return { error: stepError("channel_config", await describeRemote(res)) };
749
+ if (!res.ok) return { error: stepError("agent_config", await describeRemote(res)) };
744
750
  return { error: null, replyTokenJti: vaultWriteSigned.jti };
745
751
  } catch (err) {
746
- return { error: stepError("channel_config", err) };
752
+ return { error: stepError("agent_config", err) };
747
753
  }
748
754
  }
749
755
 
@@ -1538,37 +1544,48 @@ export async function teardownConnection(
1538
1544
  }
1539
1545
  }
1540
1546
 
1541
- // --- Channel-sink teardown (remove the channel config entry). ------------
1542
- // Fenced to event→action records: a credential connection whose HOLDER is
1543
- // the channel module must not delete an unrelated channel config entry.
1544
- if (record.kind !== "credential" && record.sink.module === "channel" && deps.channelOrigin) {
1547
+ // --- Agent-sink teardown (remove the channel config entry). --------------
1548
+ // Fenced to event→action records (a credential connection whose HOLDER is the
1549
+ // agent module must not delete an unrelated channel config entry) AND — like
1550
+ // the create-side prerequisite to the `message.deliver` action: only that
1551
+ // action created a channel config entry, so only it has one to remove. A
1552
+ // module-level gate here would issue a spurious DELETE /api/channels/<id> for
1553
+ // a channel-less action (e.g. definition.reload — `record.id` as the channel
1554
+ // fallback), wasting an agent:admin mint on a never-created channel and
1555
+ // risking a real same-named channel. Symmetric to the create-side gate.
1556
+ if (
1557
+ record.kind !== "credential" &&
1558
+ record.sink.module === "agent" &&
1559
+ record.sink.action === "message.deliver" &&
1560
+ deps.agentOrigin
1561
+ ) {
1545
1562
  const channelName =
1546
1563
  typeof record.sink.params?.channel === "string" ? record.sink.params.channel : record.id;
1547
1564
  try {
1548
1565
  const channelAdminToken = (
1549
1566
  await mint(deps, userId, {
1550
- scopes: ["channel:admin"],
1551
- audience: "channel",
1567
+ scopes: ["agent:admin"],
1568
+ audience: "agent",
1552
1569
  vaultScope: [],
1553
1570
  ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
1554
1571
  })
1555
1572
  ).token;
1556
1573
  const res = await fetchImpl(
1557
- `${deps.channelOrigin}/api/channels/${encodeURIComponent(channelName)}`,
1574
+ `${deps.agentOrigin}/api/channels/${encodeURIComponent(channelName)}`,
1558
1575
  { method: "DELETE", headers: { authorization: `Bearer ${channelAdminToken}` } },
1559
1576
  );
1560
1577
  if (!res.ok && res.status !== 404) {
1561
- errors.push({ step: "channel_config", detail: await remoteDetail(res) });
1578
+ errors.push({ step: "agent_config", detail: await remoteDetail(res) });
1562
1579
  }
1563
1580
  } catch (err) {
1564
- errors.push({ step: "channel_config", detail: errMsg(err) });
1581
+ errors.push({ step: "agent_config", detail: errMsg(err) });
1565
1582
  }
1566
1583
  }
1567
1584
 
1568
1585
  // --- Revoke the registered long-lived mints (B0, registered-mint rule). ---
1569
1586
  // Marks each tokens-registry row revoked → the revocation list at
1570
1587
  // `/.well-known/parachute-revocation.json` advertises the jtis, and every
1571
- // resource server (vault, channel) rejects the credential from its next
1588
+ // resource server (vault, agent) rejects the credential from its next
1572
1589
  // poll. Runs regardless of remote-teardown outcome — revocation is the safe
1573
1590
  // direction. Legacy records (provisioned before B0) carry no jtis: teardown
1574
1591
  // proceeds, but their tokens were never registered and ride to expiry.
@@ -1695,8 +1712,8 @@ function readProvisionType(provision: unknown): string | null {
1695
1712
 
1696
1713
  /**
1697
1714
  * Audience for a minted sink bearer. A `<module>:<verb>` scope (e.g.
1698
- * `channel:send`) takes the module namespace as its audience — matching how
1699
- * the channel validates `aud: channel`. Falls back to the sink module name.
1715
+ * `agent:send`) takes the module namespace as its audience — matching how
1716
+ * the agent validates `aud: agent`. Falls back to the sink module name.
1700
1717
  */
1701
1718
  function audienceForScope(scope: string, sinkModule: string): string {
1702
1719
  const colon = scope.indexOf(":");
@@ -1717,15 +1734,15 @@ function str(v: unknown): string {
1717
1734
  }
1718
1735
 
1719
1736
  /**
1720
- * Derive the connection id. Operator-supplied wins; else for a channel sink use
1737
+ * Derive the connection id. Operator-supplied wins; else for an agent sink use
1721
1738
  * the channel name (so the trigger + channel-config share a stable key), else a
1722
1739
  * `<srcModule>-<event>-<sinkModule>-<action>` slug.
1723
1740
  */
1724
1741
  function deriveId(rawId: unknown, source: ConnectionSource, sink: ConnectionSink): string {
1725
1742
  const supplied = str(rawId);
1726
1743
  if (supplied) return supplied.toLowerCase();
1727
- if (sink.module === "channel" && typeof sink.params?.channel === "string") {
1728
- return `channel-${sink.params.channel}`.toLowerCase();
1744
+ if (sink.module === "agent" && typeof sink.params?.channel === "string") {
1745
+ return `agent-${sink.params.channel}`.toLowerCase();
1729
1746
  }
1730
1747
  const slug = `${source.module}-${source.event}-${sink.module}-${sink.action}`
1731
1748
  .toLowerCase()
@@ -1740,8 +1757,8 @@ function channelConnectLines(
1740
1757
  ): { mcpAdd: string; launch: string } {
1741
1758
  const origin = hubOrigin.replace(/\/+$/, "");
1742
1759
  return {
1743
- mcpAdd: `claude mcp add --transport http --scope user channel-${channelName} ${origin}/channel/mcp/${channelName}`,
1744
- launch: `claude --dangerously-load-development-channels=server:channel-${channelName} --dangerously-skip-permissions`,
1760
+ mcpAdd: `claude mcp add --transport http --scope user agent-${channelName} ${origin}/agent/mcp/${channelName}`,
1761
+ launch: `claude --dangerously-load-development-channels=server:agent-${channelName} --dangerously-skip-permissions`,
1745
1762
  };
1746
1763
  }
1747
1764
 
@@ -47,6 +47,7 @@
47
47
  * in line with the admin scope-set semantics from hub#214 / #222.
48
48
  */
49
49
  import type { Database } from "bun:sqlite";
50
+ import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
50
51
  import { signAccessToken } from "./jwt-sign.ts";
51
52
  import { findSession, parseSessionCookie } from "./sessions.ts";
52
53
  import { isFirstAdmin } from "./users.ts";
@@ -72,7 +73,7 @@ export async function handleHostAdminToken(
72
73
  }
73
74
  const sid = parseSessionCookie(req.headers.get("cookie"));
74
75
  const session = sid ? findSession(deps.db, sid) : null;
75
- if (!session) {
76
+ if (!session || !sid) {
76
77
  return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
77
78
  }
78
79
  // First-admin gate. A friend account (non-first-admin user created via
@@ -89,6 +90,18 @@ export async function handleHostAdminToken(
89
90
  "host-admin token mint is restricted to the hub admin — your account home is at /account/",
90
91
  );
91
92
  }
93
+ // Admin screen-lock gate (optional, off by default). When a lock PIN is set
94
+ // AND this session isn't within an unlock window, refuse to mint — the SPA
95
+ // shows the lock screen on the 423. No PIN configured → always allowed
96
+ // (today's behavior). This mint is a PURE CHECK — it does NOT slide the idle
97
+ // window; sliding is driven only by genuine user activity (the SPA's debounced
98
+ // `/heartbeat`), so a background re-mint (e.g. the 30s version-badge poll) can't
99
+ // keep an idle tab unlocked. See the `requireUnlocked` docblock in admin-lock.ts.
100
+ // The OAuth issuer (`/oauth/*`) never reaches this endpoint, so it's
101
+ // unaffected by the lock.
102
+ if (!requireUnlocked(deps.db, sid).ok) {
103
+ return lockedResponse();
104
+ }
92
105
  const minted = await signAccessToken(deps.db, {
93
106
  sub: session.userId,
94
107
  scopes: [...HOST_ADMIN_SCOPES],