diff --git a/CHANGELOG.md b/CHANGELOG.md index 51eb0250..bd3854cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,52 @@ All notable changes to this package are documented here. +## Unreleased + +This release lets one deployment serve several authenticated people without +becoming an account system. Connector visibility comes from deployment config, +and downstream auth may be shared across the tenant or isolated per human. +Existing connectors stay shared, every visible connector stays visible, and +every interactive human stays an operator unless the deployment opts into the +new identity rules. A signed-in human may manage authentication for every +connector their code-derived view includes; operator status separately controls +tokens and global activity. Worker deployments also gain the complete Managed +OAuth callback allowlist in the shipped example and agent instructions. + +### Added + +- **Identity-derived connector views.** `identity.connectorAccess` selects + declared connector ids from the authenticated actor, subject, and principal. + The view reaches discovery, direct and program calls, status, catalogs, + observed output shapes, and paged results. Unknown ids and resolver failures + fail closed. +- **Shared and personal connector auth.** `authScope: "personal"` partitions + connector storage, encrypted credentials, OAuth state and tokens, catalogs, + and runtime observations by a hashed principal identity. Interactive members + manage auth for every visible connector: their own partition for personal + auth, or the deployment-wide grant for shared auth. `identity.operatorAccess` + reserves deployment access tokens and global activity for configured + operators. +- **Principal-bound access tokens and OAuth callbacks.** A new connecta access + token retains its creator's principal without gaining operator rights. + Personal OAuth handoffs bind a hash of state to the initiating principal for + 15 minutes before the public callback can exchange a code. + +### Changed + +- **Result pages follow authenticated subjects.** `get_result` storage is now + partitioned for every namespaced subject, including existing Clerk, Access, + and connecta-token callers. In-flight result ids created before upgrading do + not cross that storage boundary; finish paging them before deployment when + that matters. +- **Worker Managed OAuth setup.** The Worker guide, source comment, upgrade + guide, and local `AGENTS.md` require Claude's fixed callback plus ChatGPT's + stable and callback-id forms in + `dynamic_client_registration.allowed_uris`. +- **One tenant, several people.** The ethos now refuses a connecta-owned account + model while allowing externally authenticated principals, config-derived + connector visibility, and personal downstream credentials inside one tenant. + ## 0.21.2 — 2026-08-31 This patch closes two runtime isolation gaps: concurrent request scopes now diff --git a/README.md b/README.md index 923b9540..687f30b5 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,13 @@ payload-free activity log. Worker deployments can use Cloudflare Access for both MCP and operator identity; Node deployments and existing Workers can use Clerk. +One deployment may serve several authenticated people inside the same tenant. +Configuration can derive connector visibility from the admitted identity, and +each connector may keep one shared downstream grant or a separate encrypted +grant per human. Connecta does not own accounts or groups; Clerk or Cloudflare +Access remains the identity provider. See [inbound auth](./documentation/auth.md#principals-visibility-and-operators) +and [shared and personal auth](./documentation/storage-and-credentials.md#shared-and-personal-auth). + Connecta is not a platform, a marketplace, a policy engine, or a multi-tenant service. Those are decisions, and the [ethos](./ethos.md) records each one and why. diff --git a/documentation/architecture.md b/documentation/architecture.md index 1d245b8c..177dace3 100644 --- a/documentation/architecture.md +++ b/documentation/architecture.md @@ -70,7 +70,7 @@ read top to bottom. | 2 | `OPTIONS` | Each auth provider's `handleMetadata` gets a chance (CORS preflight for browser MCP clients); otherwise 204 with MCP CORS. | | 3 | `/.well-known/*` | Auth providers' `handleMetadata`, open. 404 when none handles it. | | 4 | `/health` | Open JSON: status, connector count, `serverInfo`, the configured executor's sanitized name when it has one, catalog-drift counts, admission snapshots, reserved route names, and `deployment` when `deploymentInfo` is set. Payload-free by construction, and it never joins the MCP queue. | -| 5 | `/oauth/callback/` | Downstream-OAuth completion, open, `verifyState` before `finishAuth`. | +| 5 | `/oauth/callback/` | Downstream-OAuth completion, open, `verifyState` before `finishAuth`. Personal flows first resolve the short-lived state hash to the principal partition. | | 6 | `/favicon.*`, `/ui` → `/`, the operator shells, `/ui/data` | The operator surface ([operator UI](./operator-ui.md)). The shells are open and data-free; `/ui/data` behind them is gated. Built-ins are matched before connector routes, so a connector cannot shadow a page. | | 7 | `/ui/activity` | Gated, plus the optional `activity.readGate`. `GET` only; 404 with no `activity.store.list`. | | 8 | `/mcp` | **Admission before auth**, then the auth gate, then a fresh MCP server. | @@ -93,11 +93,15 @@ any one file and a reordering reads like a harmless refactor. before interactive providers. First `ok` admits; if all fail, the last provider's challenge response is returned. No providers configured means open — development only, and it warns at construction. -3. **Refuse `?toolkit=`.** Toolkits were removed ([#178](https://github.com/zackbart/connecta/issues/178)) +3. **Derive the registry view.** Auth supplies a namespaced subject and, for a + human, a principal. `identity.connectorAccess` selects declared connector + ids. Personal connectors use the principal partition; result paging uses + the subject partition. No caller parameter selects either. +4. **Refuse `?toolkit=`.** Caller-selected toolkits were removed ([#178](https://github.com/zackbart/connecta/issues/178)) but the URLs naming them were handed out, so the parameter is a 404 rather than silently serving the full registry. Retiring a scoping boundary into fail-open is the one outcome worse than the 404. -4. **Serve.** A fresh `McpServer` per request, the seven meta-tools registered +5. **Serve.** A fresh `McpServer` per request, the seven meta-tools registered against the registry, the Apps shell resource registered (and `resources/list` deliberately answering with nothing), and the response handed back. @@ -109,7 +113,7 @@ owns or hands out, and a change usually belongs in exactly one of them: | Module | Owns | | --- | --- | -| `src/registry.ts` | The connector set, address resolution, catalog TTL/persistence/completeness, shared refresh single-flight, connector health, per-connector call limiters, and drift. Construction-time refusals live here. | +| `src/registry.ts` | The connector set, identity-scoped views, personal storage partitions, address resolution, catalog TTL/persistence/completeness, refresh single-flight, connector health, per-connector call limiters, and drift. Construction-time refusals live here. | | `src/catalog-service.ts` | Request-local tool listing, search, and describe. It coalesces reads inside one request and opts agent reads into the runtime's deferred catalog channel when one exists. | | `src/invocation.ts` | One tool call: argument validation, call admission, per-attempt timeout, retry with the connector's own `Retry-After` honoured exactly or declined, result unwrapping, size capping, and the activity record. | | `src/catalog.ts` | Ranking, description summarizing, and the compact schema renderer discovery shows. | @@ -184,10 +188,12 @@ src/ ## Sharp edges -- **The registry is shared; the request is not.** Anything you cache on the - registry is visible to every later request in that isolate. Anything you - cache per request dies with it. Putting a downstream client on the wrong side - of that line is the highest-severity mistake available here. +- **The root registry is shared; identity views are partitioned.** Shared + connector caches are visible to later requests in the isolate. Personal + connectors use a bounded principal registry, and transient results use the + authenticated subject. Anything cached per request still dies with it. + Putting a downstream client or credential on the wrong side of those lines + is the highest-severity mistake available here. - **Route order is behavior.** Moving a built-in below the connector dispatch hands a connector the ability to shadow it. Moving a mutation route below the wildcard `OPTIONS` opts it into CORS preflight. diff --git a/documentation/auth.md b/documentation/auth.md index 42fabdb6..c6c5a566 100644 --- a/documentation/auth.md +++ b/documentation/auth.md @@ -6,6 +6,63 @@ Access identities on Workers, or a mixture. Static bearers are checked first; the remaining providers keep configuration order. The first successful identity owns the activity actor for that request. +## Principals, visibility, and operators + +Connecta distinguishes three identities. The actor is the exact caller written +to activity. The subject is any stable authenticated caller and owns transient +results such as `get_result` pages. The principal is the human owner of personal +connector auth. An interactive Clerk or Access user supplies all three. A +Cloudflare service identity has an actor and subject but no principal. A +connecta access token has its own actor and subject and inherits the principal +that created it, so agents using that token reach the creator's personal +connections without becoming operators. + +`identity.connectorAccess` derives the connector ids a caller may discover and +invoke. The resolver receives authenticated identity data, never request input, +and returns `"all"` or a list of ids declared in `connectors`. An unknown id or +a thrown resolver fails the request closed. + +`identity.connectorAccess` is also the credential-management boundary. A +signed-in human may save, test, disconnect, and authorize every visible +connector: personal auth changes only that principal's partition, while shared +auth changes the deployment-wide grant for everyone who can see the connector. +Use `authScope: "personal"` when one member must not rotate another member's +connection. + +`identity.operatorAccess` separately reserves deployment-wide administration: +access-token creation and global activity history. Omit the resolver to +preserve the prior rule that every interactive human is an operator. When it is +configured, activity history is operator-only because its global event stream +contains other principals' connector names and actors. + +```ts +createConnecta({ + auth: cloudflareAccessAuth(), + identity: { + connectorAccess: ({ principal }) => + principal?.id === "user_a" + ? ["shared_docs", "personal_linear"] + : ["shared_docs"], + operatorAccess: ({ id }) => id === "user_a", + }, + connectors: [ + remoteMcp("shared_docs", { url: "https://example.com/mcp" }), + remoteMcp("personal_linear", { + url: "https://mcp.linear.app/mcp", + authScope: "personal", + auth: { type: "oauth" }, + }), + ], + executor, +}); +``` + +Identity namespaces matter. Built-in Clerk and Access providers supply one. +A custom interactive provider must set `activityActorNamespace` before its +users can own personal auth. It may still use the legacy operator behavior +without one, but connecta will not merge unnamespaced users into personal +storage. + ## Cloudflare Access on Workers [`cloudflareAccessAuth()`](https://developers.cloudflare.com/workers/configuration/cloudflare-access/) @@ -35,7 +92,8 @@ also means it is deliberately not a Node or `cloudflared` origin adapter, and it does not survive a Service Binding hop: those shapes need their own explicit trust boundary. -A human identity gets MCP and operator access. A Cloudflare service-token +A human identity gets MCP and personal-connection access. It gets operator +access unless `identity.operatorAccess` says otherwise. A Cloudflare service-token identity gets MCP access and a stable activity subject, but no `userId`, so it cannot write credentials, run downstream OAuth mutations, or issue connecta tokens. Access policy decides who reaches the Worker; connecta does not mirror @@ -48,7 +106,27 @@ the Worker. Enable [**Managed OAuth**](https://developers.cloudflare.com/cloudfl on that Worker-level application for interactive MCP clients. Cloudflare then owns the unauthenticated challenge and `/.well-known/` metadata, issues opaque RFC 8707 tokens, and resolves them into the same trusted -Worker identity. Do not add a bypass for the discovery routes. A fully +Worker identity. Managed OAuth allows no hosted client callback by default, so +enable Dynamic Client Registration and add all three values to **Allowed +redirect URIs**: + +```text +https://claude.ai/api/mcp/auth_callback +https://chatgpt.com/connector_platform_oauth_redirect +https://chatgpt.com/connector/oauth/* +``` + +Cloudflare exposes that list as +`oauth_configuration.dynamic_client_registration.allowed_uris`. It belongs to +the Access application's Managed OAuth settings, not the Access policy that +selects admitted identities. Claude uses the fixed first value. ChatGPT may use +its stable callback or a callback-id path covered by the third value. If a +client registers a different redirect, add that exact URI or the narrowest path +wildcard that covers it; do not allow the client's whole origin. Without these +entries discovery succeeds and client registration fails later, which makes a +missing allowlist look like a broken MCP server. + +Do not add a bypass for the discovery routes. A fully automated client instead uses a [Cloudflare Access service token](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/) through the `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers. @@ -102,7 +180,9 @@ secure. Each token has an immutable ID. Activity records store that ID and resolve its current friendly name only while an authorized operator reads activity. Revoked records remain as metadata tombstones so historical calls keep their -friendly attribution. +friendly attribution. New tokens also retain the creating principal. Their MCP +requests use that principal's connector visibility and personal auth while the +token itself remains the activity actor and result owner. Access tokens authenticate MCP clients; they are never operator credentials. Creation, rename, and revocation require the same eligible human identity and @@ -113,20 +193,20 @@ Issuance and revocation inherit the consistency guarantees of the configured storage adapter. Use strongly consistent storage when either change must take effect globally without a convergence window. -Operator credential mutation is a separate, narrower boundary. The +Human credential mutation is a separate, narrower boundary. The `/credentials` shell contains no secret data before authentication, and the -mutation API requires same-origin requests from an admitted operator. An MCP -bearer is never treated as an operator credential, even when it can call every -connector. +mutation API requires same-origin requests from an admitted interactive human. +That human may mutate only visible connector slots. An MCP bearer is never +treated as a browser credential, even when it can call every connector. This split is visible in recovery: - a bearer-authenticated agent may receive `recovery: "operator_config"` and pass its `operatorUrl` to a human; -- an interactive operator opens that URL, signs in, and updates the +- an interactive human with connector access opens that URL, signs in, and updates the credential; and - a bearer-only deployment still returns the handoff honestly, but mutation - remains unavailable until interactive operator auth is configured. + remains unavailable until interactive user auth is configured. See [meta-tools](./meta-tools.md#authorization-recovery) for the stable recovery envelope and [storage and credentials](./storage-and-credentials.md) for vault diff --git a/documentation/connectors.md b/documentation/connectors.md index 7f00127b..f1d00c66 100644 --- a/documentation/connectors.md +++ b/documentation/connectors.md @@ -1,5 +1,18 @@ # Connectors +Every connector may set `authScope: "shared" | "personal"`. Shared is the +default and keeps one deployment-wide downstream grant. Personal auth requires +a stable human principal and partitions connector state, credentials, OAuth, +catalogs, and observed shapes by that principal. Connector visibility is a +separate deployment rule under `identity.connectorAccess`; hiding a connector +does not change who owns its auth. See [shared and personal auth](./storage-and-credentials.md#shared-and-personal-auth). + +`authScope` partitions connecta-owned context, not arbitrary variables captured +by connector code. A custom personal connector must read auth from +`ctx.credential` or `ctx.storage`; a secret closed over by its handler remains +shared JavaScript state. `remoteMcp()` rejects the equivalent mistake when +literal headers are combined with personal scope. + Connectors are the boundary between Connecta's fixed meta-tool surface and downstream capabilities. Prefer a prebuilt connection when Connecta maintains one for the provider. Use `api()` to define a deliberate HTTP API surface and diff --git a/documentation/meta-tools.md b/documentation/meta-tools.md index 6203bb13..f336e749 100644 --- a/documentation/meta-tools.md +++ b/documentation/meta-tools.md @@ -307,9 +307,10 @@ credential. The tool accepts no secret. `force` applies only to OAuth and may discard its stored grant before restarting consent. Static credential values are written -only through the same-origin, Clerk-operator credential route. After OAuth -consent or an operator update, retry the original operation; a static update is -read from the vault on the next call and needs no redeploy. +only through the same-origin interactive-user credential route, and only for a +connector visible to that user. After OAuth consent or a human update, retry +the original operation; a static update is read from the vault on the next call +and needs no redeploy. ## Routing recovery diff --git a/documentation/operations.md b/documentation/operations.md index 662dd4d0..b7715371 100644 --- a/documentation/operations.md +++ b/documentation/operations.md @@ -84,6 +84,7 @@ optional. | `connectors` | — (required) | the connector set ([connectors](./connectors.md)) | | `executor` | — (required) | the sandbox `execute_code` runs in ([code mode](./code-mode.md#what-an-executor-must-implement)) | | `auth?` | none ⇒ open (dev only) | one `InboundAuth` or an array; bearer providers are checked before interactive providers ([inbound auth](./auth.md)) | +| `identity?` | all connectors; every interactive human is an operator | `{ connectorAccess?, operatorAccess? }` derives the request's connector view and shared-auth authority from its authenticated identity ([principals](./auth.md#principals-visibility-and-operators)) | | `storage?` | `memoryStorage()` | the one state seam for catalogs, result paging, credentials, and access tokens ([storage](./storage-and-credentials.md)) | | `publicUrl?` | per-request origin | public base URL; an HTTPS value also redirects inbound HTTP | | `logger?` | `console`, prefixed `[connecta]` | `{ debug, info, warn, error }` | @@ -247,6 +248,7 @@ in. | `executor-admission.test.ts` | the portable bounded FIFO both pools use: active and queue ceilings, stable retryable overload, queue timeout, cancellation removal, idempotent release, shutdown | | `guarded-fetch.test.ts` | the guarded transport — construction, request building, destination confinement, and response handling | | `guest-api-contract.test.ts` | the shared guest contract on the Dynamic Worker, including caught call, typed inline describe recovery, discovery, utility, batch, and budget failure codes; plus the real authority boundary — local `data:` fetch, denied egress, unresolved DNS, empty environment paths, unavailable filesystem/HTTP builtins, and present runtime globals | +| `identity-scope.test.ts` | identity-derived connector visibility, personal credential isolation, shared-auth operator control, and personal OAuth callback ownership | | `linear-provider.test.ts` | the Linear proxy's construction, guide, plan-aware catalog superset, and current workspace, template, and issue-sharing classifications | | `meta-tools-call.test.ts` | registry-backed calls: structured errors, truncation and `get_result`, per-connector result bounds, JSON representation failures, MCP content bounds, and offset alignment | | `meta-tools-search.test.ts` | registry-backed discovery: bounded search with page and address maxima, compact and JSON schemas with constraints, typed describe recovery and suggestions, and structured-result compatibility | @@ -280,7 +282,7 @@ justification for *not* re-running it in workerd, so "it was easier" is not one. | Suite | Covers | Why Node | | --- | --- | --- | -| `deployment-shapes.test.ts` | the Worker as the only example with a loader-only sandbox, one Node template that is also its own container, the same source running locally and in the container, the Node template's pinned esbuild install-script approval, the full operator surface in both, a template that cannot start on its own `.env.example`, a Worker README naming every optional peer its entrypoint imports, and the initializer's `.gitignore` staying in step | walks the template and example trees with Node filesystem APIs | +| `deployment-shapes.test.ts` | the Worker as the only example with a loader-only sandbox, its agent instructions and setup guide pinning Claude and both ChatGPT Managed OAuth callback forms, one Node template that is also its own container, the same source running locally and in the container, the Node template's pinned esbuild install-script approval, the full operator surface in both, a template that cannot start on its own `.env.example`, a Worker README naming every optional peer its entrypoint imports, and the initializer's `.gitignore` staying in step | walks the template and example trees with Node filesystem APIs | | `doc-links.test.ts` | the documentation checker itself — local file and fragment resolution, repository URLs resolved back to the checkout, duplicate heading slugs, fenced-code exclusion, and useful failures | spawns the Node checker against filesystem fixtures | | `doctor-cli.test.ts` | `connecta doctor`'s executor line and credentials end to end — the sandbox the deployment reports is the one named, an unidentifiable executor gets an executor-neutral line, a hostile name is bounded, and a complete Cloudflare Access service-token pair is accepted while a partial pair is refused | spawns the CLI against a Node HTTP deployment over real sockets | | `drift-check.test.ts` | the maintainer drift checker — hosted-provider credential framing, recorded touched endpoints, a quiet revision bump, clear failures for an unavailable spec/manifest/credential, `$ref` traversal, and one well-formed row per endpoint | spawns the Node checker against filesystem fixtures | diff --git a/documentation/operator-ui.md b/documentation/operator-ui.md index 0fcc14fc..19b4e073 100644 --- a/documentation/operator-ui.md +++ b/documentation/operator-ui.md @@ -5,10 +5,10 @@ the authentication material behind it. It is a small Preact app compiled by the repository's own esbuild step and inlined into a data-free server shell. Read [`ethos.md`](../ethos.md) first. The boundary this subsystem lives inside -is the operator row in its decisions table: **operator routes may manage -authentication material for capabilities declared in deployment configuration, -and may not change the connector set, the tool catalog or annotations, requested -OAuth scopes, admission policy, authorization rules, or caller tool scope.** +is the human-management invariant: **members may manage authentication material +for every connector their code-derived view includes, operators may also manage +deployment tokens and global activity, and neither may change the connector set, tool catalog, +annotations, requested OAuth scopes, admission policy, or identity rules.** `test/operator-boundary.test.ts` proves it after every mutation route. Both deployment shapes ship the whole feature set behind it, because pages for @@ -52,6 +52,15 @@ server reads the resulting runtime identity. Sign out navigates to `/cdn-cgi/access/logout`. Mutations still require an exact same-origin `Origin`; an ambient cookie does not weaken the CSRF boundary. +The shell is shared by members and operators. `/ui/data` uses the same +identity-scoped registry view as `/mcp`, so it cannot list a connector the +current caller cannot discover. A member sees credential and OAuth controls for +every visible connector. Personal actions resolve to that member's principal +partition; shared actions change the deployment-wide grant. The access-token +and global activity pages require `identity.operatorAccess`. Existing +deployments that omit that resolver keep every interactive human as an +operator. + This runtime selection is the Clerk migration seam. A deployment may contain both providers: before Worker-level Access is attached, the data-free shell selects Clerk; after Access supplies `ctx.access`, it selects ambient auth. That diff --git a/documentation/request-admission.md b/documentation/request-admission.md index 149a7d52..69a0d623 100644 --- a/documentation/request-admission.md +++ b/documentation/request-admission.md @@ -49,7 +49,8 @@ which is also why `/health` always has a code-admission shape to report. The request pool is global FIFO across identities. It is a capacity boundary, not tenant fairness: one busy caller can occupy it. Per-tenant fairness needs a -policy above connecta, and one deployment serves one audience anyway +policy above connecta, and one deployment still serves one tenant even when +identity rules give its principals different connector views ([`ethos.md`](../ethos.md)), so a global queue is not pretending to supply something it does not. diff --git a/documentation/storage-and-credentials.md b/documentation/storage-and-credentials.md index 99fec927..243882c9 100644 --- a/documentation/storage-and-credentials.md +++ b/documentation/storage-and-credentials.md @@ -6,7 +6,7 @@ token is an independent record rather than one shared, race-prone manifest. The built-in memory and file adapters implement it, as does the Cloudflare KV example. -Connectors may declare an operator-managed `credential` slot. When +Connectors may declare a human-managed `credential` slot. When `credentials.encryptionKey` is configured, Connecta encrypts values in the deployment storage and exposes read-only access only through that connector's `ctx.credential`. Values, masked values, call arguments, and raw errors never @@ -27,13 +27,40 @@ Credential mutation is intentionally narrower than MCP access: - a static bearer may call tools and receive the operator handoff, but it cannot write credentials; -- only an admitted Clerk user may use the same-origin credential mutation - routes; and +- an admitted interactive human may mutate credentials for every visible + connector: their own partition for personal auth, or the deployment-wide + value for shared auth; and - saving, replacing, testing, or removing a value never returns that value. -The vault is read for each call. Once an operator saves a replacement, +The vault is read for each call. Once a signed-in human saves a replacement, the agent can retry immediately without restarting or redeploying Connecta. +## Shared and personal auth + +Connector auth defaults to `authScope: "shared"`. Its credential, OAuth state, +tokens, catalog cache, and connector storage belong to the deployment. Set +`authScope: "personal"` when every human principal needs a separate downstream +account: + +```ts +remoteMcp("linear", { + url: "https://mcp.linear.app/mcp", + authScope: "personal", + auth: { type: "oauth" }, +}); +``` + +Personal connectors disappear from a request that has no stable human +principal. For a principal that can see one, connecta partitions connector +storage, encrypted vault records, catalog caches, OAuth generations, and +observed result shapes under an opaque SHA-256 identity key. Results used by +`get_result` are partitioned by the authenticated subject, so one token cannot +page another token's call even when both tokens belong to the same principal. + +Literal `auth: { type: "headers" }` cannot be personal because its secret lives +in deployment code. `remoteMcp()` refuses that combination at construction. +Use operator-managed credential auth or OAuth instead. + ## A remote MCP connector's static credential `remoteMcp()` accepts a third auth shape beside OAuth and literal headers: @@ -97,6 +124,17 @@ Registration and token envelopes are bound to the validated authorization server `issuer`. An unbound pre-0.9 envelope is upgraded in place on its first issuer-aware read, preserving the existing grant. +For personal OAuth, the authorization handoff also stores a 15-minute mapping +from a SHA-256 digest of `state` to the principal partition. The public callback +uses that mapping before it verifies state or exchanges the code. Neither the +browser nor a callback parameter can select a principal. The callback deletes +the mapping before it exchanges the code, so a second callback cannot replay +the principal handoff in strongly consistent storage. Cloudflare KV deletion +is eventually consistent, so handoff consumption there is best-effort across +PoPs; the downstream authorization code remains single-use. If the callback +request also carries an interactive identity, Connecta refuses it when that +principal did not start the flow. + If later discovery resolves a different issuer, Connecta does not send the old client identifier or tokens to it. The provider publishes a new generation epoch, makes every older credential namespace unreadable, cleans up the retired diff --git a/documentation/upgrading.md b/documentation/upgrading.md index 06917f02..94f28407 100644 --- a/documentation/upgrading.md +++ b/documentation/upgrading.md @@ -207,6 +207,22 @@ first, so cross them bottom-up: start at the oldest one still above this deployment's pin and work back up the page, because each boundary assumes the older ones are already done. +### 0.21.2 → Unreleased + +Connector and user policy remain config-as-code. If `identity.connectorAccess` +is configured, every interactive human may now manage the authentication of +each connector that resolver makes visible. A personal connector changes only +that principal's partition; a shared connector changes the deployment-wide +grant. Keep shared connectors out of a member's view, or change them to +`authScope: "personal"`, when that member must not rotate the shared grant. +`identity.operatorAccess` continues to govern deployment access tokens and +global activity. + +Paged results also move under the authenticated subject's storage partition. +Finish any important in-flight `get_result` sequence before upgrading; its old +result id is not readable from the new partition after deployment. No persisted +connector catalog or credential migration is required. + ### 0.20.0 → 0.21.2 0.21.2 adds no deployment migration beyond 0.21.0. The boundary is additive @@ -244,6 +260,21 @@ For a Worker currently using Clerk, keep rollback live through the cutover: tag>" }`, not a hostname application for the `workers.dev` URL: the latter gates traffic but does not provide `ctx.access`. Create an Access service token and a **Service Auth** policy for doctor and fully unattended clients. + In the application's Managed OAuth settings, enable Dynamic Client + Registration and add these three **Allowed redirect URIs**: + + ```text + https://claude.ai/api/mcp/auth_callback + https://chatgpt.com/connector_platform_oauth_redirect + https://chatgpt.com/connector/oauth/* + ``` + + They map to + `oauth_configuration.dynamic_client_registration.allowed_uris` in the + Access API, not to the identity policy. The two ChatGPT entries cover its + stable and callback-id forms. An empty list fails client registration only + after discovery, so do not treat a working `/.well-known/*` response as + proof that this step is complete. Do not create a bypass for `/.well-known/*`; Managed OAuth owns that discovery surface. diff --git a/ethos.md b/ethos.md index eb020eab..12d0f6d5 100644 --- a/ethos.md +++ b/ethos.md @@ -8,8 +8,8 @@ preserve. A contradiction needs a design decision, not a drive-by edit. - **One MCP endpoint, one programmable surface.** Every integration you chose sits behind a capability catalog that agents reach by writing JavaScript, ringed by a few explicit tools for the boundaries code must not cross. -- **A deployment is a small config-as-code file.** Changing what agents can - reach is an edit and a redeploy. One deployment, one tenant, one audience. +- **A deployment is config-as-code.** One tenant and connector set; principals + receive config-derived views. - **Curated when available, open when not.** Prefer a maintained prebuilt connection; `remoteMcp()` and `api()` stay first-class for everything else. Every path yields the same `Connector` with the same rules. @@ -30,8 +30,8 @@ preserve. A contradiction needs a design decision, not a drive-by edit. - **Not a platform.** No runtime registration, admin-editable capability, policy engine, approvals, or pauses. - **Not a schema ingester.** No OpenAPI or GraphQL → tools. -- **Not multi-tenant.** No account model or per-user credential store; scope - stays connector-level, and ambiguity stops rather than guesses. +- **Not multi-tenant.** No accounts, groups, or sessions. Inbound auth owns + identity; personal state stays within one tenant. - **Not stateful.** No protocol sessions, no server push; scope resolves per request. - **Not a nanny.** Credentials fail loudly at use; nothing probes one. @@ -47,7 +47,7 @@ CHANGELOG, not here. | Decision | Verdict | Why | | --- | --- | --- | | OpenAPI / GraphQL ingestion | refused | the disease is a tool nobody chose — a document authored it; hand-written literals, even through a shared factory, are still authorship | -| Multi-tenancy / account model | refused | one deployment per tenant; deploy again | +| Multi-tenancy / account model | refused | one deployment per tenant; inbound auth owns identity | | Policy engine, approvals, pauses | refused | the host asks the human; connecta only annotates | | Runtime connector registration | refused | config-as-code is the security model | | Provider registry / marketplace | refused | prebuilt connections are imports; discovery happens in docs ([#297](https://github.com/zackbart/connecta/issues/297)) | @@ -67,7 +67,7 @@ CHANGELOG, not here. | Legacy embedded `UIResource` delivery | refused | superseded upstream, rendered by no client we face ([#266](https://github.com/zackbart/connecta/issues/266)) | | Effect as the core effect system | refused | −4% of the core for +75 KB gzip and a second async paradigm; re-measure at v4 stable ([#470](https://github.com/zackbart/connecta/issues/470)) | | Shared bounded queue under both admission controllers | refused | built and measured −17 lines for a hook-parameterised abstraction ([#453](https://github.com/zackbart/connecta/issues/453)) | -| Toolkits (scoped views) | removed | deploy per audience ([#178](https://github.com/zackbart/connecta/issues/178)) | +| Caller-selected toolkits | removed | only config may derive an identity's connector view ([#178](https://github.com/zackbart/connecta/issues/178)) | | Proactive credential liveness | removed | fail-at-use is enough ([#179](https://github.com/zackbart/connecta/issues/179)) | | Classic (executor-free) surface | removed | an executor is mandatory ([#273](https://github.com/zackbart/connecta/issues/273)) | | Per-result lexical query coverage | removed | did not earn its response bytes in a precommitted gate ([#323](https://github.com/zackbart/connecta/issues/323)) | @@ -91,10 +91,10 @@ Breaking one is a design change wearing a disguise. - **A downstream catalog is complete or it is a failure.** A partial catalog is never cached, persisted, or served. - **Activity is payload-free by construction.** The event type has nowhere to put arguments, results, code, or raw errors. - **An observed shape is never a declaration.** Names and broad types only, labeled, and gone behind any declared schema. -- **Credentials never leave the host.** Encrypted at rest, readable only by the owning connector, rendered by nothing. +- **Credentials never leave the host.** Encrypted at rest, readable only by the owning connector and, for personal auth, its owning principal; rendered by nothing. - **Import-graph purity.** Nothing reachable from the root entry imports a `node:` builtin. - **The published surface is a boundary.** Heavyweight or platform-bound code goes behind an optional-peer subpath. -- **Operator routes manage authentication material, never declared capability.** A downstream catalog is discovered, not declared. +- **Human routes manage auth, never capability.** Signed-in humans manage auth for visible connectors; operators also manage tokens and global activity. - **Structural mistakes throw at construction.** Booting into the wrong shape is worse than not booting. Connecta began as a radical simplification of diff --git a/examples/worker/AGENTS.md b/examples/worker/AGENTS.md new file mode 100644 index 00000000..c4d6d999 --- /dev/null +++ b/examples/worker/AGENTS.md @@ -0,0 +1,44 @@ +# Working on this Connecta Worker deployment + +This repository is deployment configuration, not a copy of Connecta itself. + +- Edit `src/index.ts` for connectors, authentication, storage, and public URL. +- Keep `cloudflareAccessAuth()` as the inbound auth provider. Cloudflare Access + authenticates the request before the Worker runs; do not add JWT parsing or a + second Worker-side identity gate. +- Attach Access to the Worker itself, not only its hostname. Enable Managed + OAuth and Dynamic Client Registration on that Access application. +- Managed OAuth's **Allowed redirect URIs** must contain all three entries + below. This is application configuration under + `oauth_configuration.dynamic_client_registration.allowed_uris`, not an + Access Allow policy: + + ```text + https://claude.ai/api/mcp/auth_callback + https://chatgpt.com/connector_platform_oauth_redirect + https://chatgpt.com/connector/oauth/* + ``` + + The first is Claude's hosted MCP callback. The two ChatGPT entries cover its + stable callback and its callback-id form. An empty allowlist lets Access + discovery work but makes client registration fail with `redirect_uri` not + allowed. If a client presents a different callback, copy that exact URI from + its registration attempt and add the narrowest matching entry rather than + broadening the allowlist to an entire origin. +- Keep `new DynamicWorkerExecutor({ loader: env.LOADER })` loader-only. Do not + add bindings, modules, or outbound access to generated code. +- Keep credentials in Worker secrets. Never commit credential values, Access + service-token secrets, or `CREDENTIAL_ENCRYPTION_KEY`. +- Add application logic only inside deliberate `api()` connector handlers. + Do not copy or modify Connecta package internals here. +- Prefer `api()` when the agent must see an exact reviewed capability set; + `remoteMcp()` follows the downstream server's evolving tool catalog. +- Use Access service credentials for `connecta doctor` and unattended clients. + A `cta_` token or static Connecta bearer cannot cross the Access edge alone. +- Run the repository's `npm run check:examples` after configuration changes. + After deployment, connect both Claude and ChatGPT to `/mcp` and + complete their browser authorization flows before calling setup complete. + +Do not add alternate entrypoints, policy layers, generated connector catalogs, +or runtime connector registration. Keep the deployment small enough to review +as configuration. diff --git a/examples/worker/README.md b/examples/worker/README.md index 1276920d..77f93697 100644 --- a/examples/worker/README.md +++ b/examples/worker/README.md @@ -46,15 +46,56 @@ wrangler deploy Cloudflare Access to the Worker itself (the API destination type is `worker`, not a hostname application) and choose the account, email-domain, or advanced Zero Trust policy that owns admission. Enable **Managed OAuth** on -that Access application for interactive MCP clients. Access then serves OAuth -discovery and turns the client's opaque token into the trusted `ctx.access` -identity connecta reads. A cron job or CI client uses an Access service token -instead. +that Access application for interactive MCP clients, turn on Dynamic Client +Registration, and add these three entries under **Allowed redirect URIs**: + +```text +https://claude.ai/api/mcp/auth_callback +https://chatgpt.com/connector_platform_oauth_redirect +https://chatgpt.com/connector/oauth/* +``` + +The Claude entry is its fixed hosted-MCP callback. ChatGPT may register either +its stable callback or a callback-id URL, so both forms are intentional. These +are Managed OAuth application settings, represented by +`oauth_configuration.dynamic_client_registration.allowed_uris` in the Access +API; they do not belong in the Access Allow policy that decides who may sign +in. Leaving the list empty is a footgun: discovery still works, then Dynamic +Client Registration fails because the callback is not allowed. If either +client presents a new redirect URI, copy that exact value from the registration +attempt and add the narrowest matching entry rather than allowing its entire +origin. + +Access then serves OAuth discovery and turns the client's opaque token into the +trusted `ctx.access` identity connecta reads. A cron job or CI client uses an +Access service token instead. + +Through the API, the relevant part of the application is: + +```json +{ + "oauth_configuration": { + "enabled": true, + "dynamic_client_registration": { + "enabled": true, + "allowed_uris": [ + "https://claude.ai/api/mcp/auth_callback", + "https://chatgpt.com/connector_platform_oauth_redirect", + "https://chatgpt.com/connector/oauth/*" + ] + } + } +} +``` Cloudflare's [Worker Access guide](https://developers.cloudflare.com/workers/configuration/cloudflare-access/) owns the dashboard/API steps; its [Managed OAuth guide](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/) owns client registration, redirect allowlists, and token lifetimes. +[`AGENTS.md`](./AGENTS.md) repeats the callback invariant for coding agents +working in a copied deployment. Do not remove the entries there when changing +the Access policy or application. + The checked-in `access.dev` block gives `wrangler dev` a local operator identity. Remove the block to test the missing-Access refusal. It has no effect on a deployed Worker's production identity. @@ -75,13 +116,9 @@ connecta's manifest but never installed with it, and published as one outside and npm says so at install time instead of leaving a Worker to discover the skew in production ([#376](https://github.com/zackbart/connecta/issues/376)). -`cloudflareAccessAuth()` has no dependency of its own. A deployment keeping -Clerk for rollback still installs `@clerk/backend` and keeps the commented -provider shape in `src/index.ts` until the migration is verified. - -```sh -npm install @clerk/backend # migration window only -``` +`cloudflareAccessAuth()` has no dependency of its own. This Worker example has +no Clerk import, secret, package, or fallback provider. Docker deployments keep +the Clerk path in the Node template. Then point an MCP client at `/mcp`, and open `/` for Connections. Credentials is at `/credentials`, named MCP access tokens are at @@ -96,9 +133,21 @@ as deployed; the fourth needs a database, so it is commented in place. **Operator sign-in** is the `cloudflareAccessAuth()` entry in `src/index.ts`. Access authenticates before the Worker runs. A human Access identity can use -MCP and operator pages; a service-token identity can use MCP but cannot write a -credential, run downstream OAuth, or issue a connecta token. Narrow admission -in the Access policy rather than repeating email domains or groups in code. +MCP and human-management pages; a service-token identity can use MCP but cannot +write a credential, run downstream OAuth, or issue a connecta token. Cloudflare +still owns the outer application admission policy, but Connecta's user roster, +connector access, and deployment roles stay in `src/index.ts`. + +**Several users** need no second auth system or Connecta account dashboard. +Uncomment the `identity` block in `src/index.ts` to derive connector ids and +deployment-operator membership from the Access principal. Connectors remain +visible to everyone and every human remains an operator when that block is +absent. A signed-in human may edit auth for every connector their view includes. +Add `authScope: "personal"` when each user should connect a different downstream +account; leave it shared only when any user with connector access may rotate the +deployment-wide grant. Static headers stay shared because their value lives in +deployment configuration. See [inbound identity](../../documentation/auth.md#principals-visibility-and-operators) +for the resolver contract. **The credential vault** is `credentials: { encryptionKey: … }`, backed by the same KV namespace as everything else and encrypted with the diff --git a/examples/worker/src/index.ts b/examples/worker/src/index.ts index 560d8256..587e32d9 100644 --- a/examples/worker/src/index.ts +++ b/examples/worker/src/index.ts @@ -17,15 +17,17 @@ * 1. `npm install` in the connecta package root (../../ from here) so the * package import and wrangler resolve. A copy in its own repository * installs `@zackbart/connecta @cloudflare/codemode` instead. Codemode is - * an optional peer; a migrating deployment also keeps `@clerk/backend` - * until it removes the commented rollback provider below. + * an optional peer. * 2. Create a KV namespace and put its id in wrangler.jsonc under `kv_namespaces`. * 3. Set secrets: * wrangler secret put DOWNSTREAM_TOKEN * wrangler secret put CREDENTIAL_ENCRYPTION_KEY * and PUBLIC_URL as a plain var in wrangler.jsonc. - * 4. Attach Cloudflare Access to this Worker. Enable Managed OAuth on the - * Access application for interactive MCP clients. + * 4. Attach Cloudflare Access to this Worker. Enable Managed OAuth and + * Dynamic Client Registration. Its Allowed redirect URIs must include + * Claude's https://claude.ai/api/mcp/auth_callback plus ChatGPT's + * https://chatgpt.com/connector_platform_oauth_redirect and + * https://chatgpt.com/connector/oauth/* forms (see ../AGENTS.md). * 5. Use the Workers Paid plan required by the `worker_loaders` binding. * 6. `wrangler deploy` from this folder (examples/worker), where wrangler.jsonc * lives. Point your MCP client at `/mcp`. @@ -37,17 +39,12 @@ import { remoteMcp, } from "@zackbart/connecta"; import { cloudflareAccessAuth } from "@zackbart/connecta/auth/cloudflare-access"; -// Rollback for a deployment migrating from Clerk: -// import { clerkAuth } from "@zackbart/connecta/auth/clerk"; import { cloudflareKvStorage } from "./cloudflare-kv.js"; // Activity history, off by default because it needs a D1 database. // import { d1ActivityStore } from "./d1-activity.js"; interface Env { CONNECTA_KV: KVNamespace; - // Keep these during a Clerk migration until Access has been verified: - // CLERK_PUBLISHABLE_KEY: string; - // CLERK_SECRET_KEY: string; /** * Base64 32-byte AES key encrypting operator-managed credentials in KV. * Unset means no vault: /credentials stays read-only and connecta says so at @@ -75,20 +72,24 @@ function build(env: Env) { // operator pages; a service token may use MCP but cannot mutate operator // state. Neither path asks connecta to parse a JWT. cloudflareAccessAuth(), - // Leave the previous Clerk provider below this entry during migration. - // It is a rollback path until Worker-level Access is detached; Access - // itself decides whether a request reaches this array. - // clerkAuth({ - // publishableKey: env.CLERK_PUBLISHABLE_KEY, - // secretKey: env.CLERK_SECRET_KEY, - // publicUrl: env.PUBLIC_URL, - // allowedDomains: ["acme.com"], - // }), ], - // Connectors that declare a `credential` slot become editable at - // /credentials, encrypted with this key before anything reaches KV. A - // saved replacement takes effect on the next call — no redeploy, and no - // liveness probe: credentials fail at use. + // Optional code-owned roster. Access proves the identity; connecta derives + // connector visibility and deployment-operator status from the stable id + // it supplies. A signed-in human may manage auth for every connector this + // view includes. Omit the block to keep every connector visible and every + // human a deployment operator. + // identity: { + // connectorAccess: ({ principal }) => + // principal?.id === "ACCESS_USER_UUID" + // ? ["notion", "echo"] + // : ["echo"], + // operatorAccess: ({ id }) => id === "ACCESS_USER_UUID", + // }, + // Connectors that declare a `credential` slot become editable by every + // signed-in human who can see that connector at /credentials, encrypted + // with this key before anything reaches KV. A saved replacement takes + // effect on the next call — no redeploy, and no liveness probe: + // credentials fail at use. // // The key is the vault, not the page: /credentials is a list of connector // slots, so it stays hidden until a connector declares one. Neither @@ -123,6 +124,9 @@ function build(env: Env) { // type: "credential", // credential: { label: "Notion internal integration token" }, }, + // Use `authScope: "personal"` with OAuth or credential auth when each + // Access user connects their own downstream account. Literal headers + // are deployment-owned and cannot be personal. }), api("echo", { description: "Echo — text transforms", diff --git a/src/access-tokens.ts b/src/access-tokens.ts index af4d1e74..5fba94ae 100644 --- a/src/access-tokens.ts +++ b/src/access-tokens.ts @@ -1,4 +1,10 @@ -import type { AuthResult, InboundAuth, KVStorage } from "./types.js"; +import type { + AuthResult, + IdentityReference, + InboundAuth, + KVStorage, +} from "./types.js"; +import { validIdentityReference } from "./identity.js"; const TOKEN_PREFIX = "cta_"; const TOKEN_BYTES = 32; @@ -18,6 +24,7 @@ interface StoredAccessToken { tokenPrefix: string; createdAt: string; createdBy: string; + principal?: IdentityReference; revokedAt?: string; revokedBy?: string; } @@ -96,6 +103,8 @@ function parseRecord(raw: string): StoredAccessToken { typeof value.tokenPrefix !== "string" || typeof value.createdAt !== "string" || typeof value.createdBy !== "string" || + (value.principal !== undefined && + !validIdentityReference(value.principal)) || (value.revokedAt !== undefined && typeof value.revokedAt !== "string") || (value.revokedBy !== undefined && @@ -205,7 +214,10 @@ export class AccessTokenManager { .map(metadata); } - async create(name: unknown, createdBy: string): Promise { + async create( + name: unknown, + createdBy: string | IdentityReference, + ): Promise { const normalizedName = normalizeName(name); const active = (await this.list()).filter((token) => !token.revokedAt); if (active.length >= this.maxActive) { @@ -226,7 +238,12 @@ export class AccessTokenManager { tokenHash: hash, tokenPrefix: token.slice(0, 12), createdAt: new Date().toISOString(), - createdBy, + createdBy: typeof createdBy === "string" + ? createdBy + : `${createdBy.namespace}:${createdBy.id}`, + ...(typeof createdBy === "string" + ? {} + : { principal: { ...createdBy } }), }; await this.storage.set(recordKey(record.id), JSON.stringify(record)); try { @@ -284,6 +301,10 @@ export class AccessTokenManager { if (!record || record.revokedAt || record.tokenHash !== hash) { return unauthorized(); } - return { ok: true, subjectId: record.id }; + return { + ok: true, + subjectId: record.id, + ...(record.principal ? { principal: { ...record.principal } } : {}), + }; } } diff --git a/src/connectors/api.ts b/src/connectors/api.ts index 1ab70ddf..483b0c6f 100644 --- a/src/connectors/api.ts +++ b/src/connectors/api.ts @@ -51,6 +51,8 @@ export interface ApiOptions { /** Human-readable display name; the connector id remains the address prefix. */ title?: string; description?: string; + /** Downstream auth ownership. Defaults to one shared deployment grant. */ + authScope?: "shared" | "personal"; /** * Max inline result size (bytes) for this connector's tools before * call_tool truncates and stashes the full text for get_result @@ -131,6 +133,7 @@ export function api(id: string, opts: ApiOptions): Connector { kind: "api", ...defined({ description: opts.description, + authScope: opts.authScope, maxResultBytes: opts.maxResultBytes, callAdmission: opts.callAdmission, usageGuide: opts.usageGuide, diff --git a/src/connectors/remote-mcp.ts b/src/connectors/remote-mcp.ts index 82a5ce5c..954f1eb3 100644 --- a/src/connectors/remote-mcp.ts +++ b/src/connectors/remote-mcp.ts @@ -103,6 +103,8 @@ export interface RemoteMcpOptions { /** Human-readable display name; the connector id remains the address prefix. */ title?: string; description?: string; + /** Downstream auth ownership. Defaults to one shared deployment grant. */ + authScope?: "shared" | "personal"; /** * Max inline result size (bytes) for this connector's tools before * call_tool truncates and stashes the full text for get_result @@ -552,6 +554,13 @@ interface ConnectionState { * server or hide other connectors). */ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector { + if (opts.authScope === "personal" && opts.auth?.type === "headers") { + throw new Error( + `[connecta] connector "${id}" cannot combine authScope "personal" ` + + "with static headers. Use credential or OAuth auth so each principal " + + "can own a different grant.", + ); + } // Weak keys ensure a completed request does not leave its SDK client, // transport, response bodies, AbortSignals, or connection promise reachable // from the isolate singleton. Those are request-bound in Cloudflare Workers. @@ -1059,6 +1068,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector { ...(opts.description !== undefined ? { description: opts.description } : {}), + ...(opts.authScope !== undefined ? { authScope: opts.authScope } : {}), ...(opts.maxResultBytes !== undefined ? { maxResultBytes: opts.maxResultBytes } : {}), diff --git a/src/credentials.ts b/src/credentials.ts index 851b0043..1f409e17 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -198,8 +198,10 @@ export function describeCredentialTestMismatch( "`testCredentials`"; } -function storageKey(connectorId: string): string { - return `conn:${connectorId}:credential:v1`; +function storageKey(connectorId: string, owner?: string): string { + return owner + ? `principal:${owner}:conn:${connectorId}:credential:v1` + : `conn:${connectorId}:credential:v1`; } function bytesToBase64(bytes: Uint8Array): string { @@ -321,12 +323,19 @@ export class CredentialVault { ); } - private additionalData(connectorId: string): Uint8Array { - return encoder.encode(`connecta:credential:${connectorId}:v1`); + private additionalData(connectorId: string, owner?: string): Uint8Array { + return encoder.encode( + owner + ? `connecta:credential:principal:${owner}:${connectorId}:v1` + : `connecta:credential:${connectorId}:v1`, + ); } - private async read(connectorId: string): Promise { - const raw = await this.storage.get(storageKey(connectorId)); + private async read( + connectorId: string, + owner?: string, + ): Promise { + const raw = await this.storage.get(storageKey(connectorId, owner)); if (!raw) return null; const envelope = parseEnvelope(raw); try { @@ -334,7 +343,7 @@ export class CredentialVault { { name: "AES-GCM", iv: base64ToBytes(envelope.iv), - additionalData: this.additionalData(connectorId), + additionalData: this.additionalData(connectorId, owner), }, await this.key, base64ToBytes(envelope.ciphertext), @@ -345,19 +354,27 @@ export class CredentialVault { } } - async get(connectorId: string, field = "value"): Promise { - return (await this.read(connectorId))?.values[field] ?? null; + async get( + connectorId: string, + field = "value", + owner?: string, + ): Promise { + return (await this.read(connectorId, owner))?.values[field] ?? null; } async getAll( connectorId: string, + owner?: string, ): Promise { - const credential = await this.read(connectorId); + const credential = await this.read(connectorId, owner); return credential ? { ...credential.values } : null; } - async metadata(connectorId: string): Promise { - const credential = await this.read(connectorId); + async metadata( + connectorId: string, + owner?: string, + ): Promise { + const credential = await this.read(connectorId, owner); if (!credential) return null; const fields = Object.fromEntries( Object.entries(credential.values).map(([field, value]) => [ @@ -384,17 +401,19 @@ export class CredentialVault { connectorId: string, value: string, updatedBy: string, + owner?: string, ): Promise { // `await` (not a bare promise return) so a validation throw inside setAll // never sits handler-less for the thenable-adoption microtask — workerd // reports that gap as an unhandled rejection. - return await this.setAll(connectorId, { value }, updatedBy); + return await this.setAll(connectorId, { value }, updatedBy, owner); } async setAll( connectorId: string, values: ConnectorCredentialValues, updatedBy: string, + owner?: string, ): Promise { const normalized = validateValues(values); const plaintext: CredentialPlaintext = { @@ -407,7 +426,7 @@ export class CredentialVault { { name: "AES-GCM", iv, - additionalData: this.additionalData(connectorId), + additionalData: this.additionalData(connectorId, owner), }, await this.key, encoder.encode(JSON.stringify(plaintext)), @@ -418,11 +437,14 @@ export class CredentialVault { iv: bytesToBase64(iv), ciphertext: bytesToBase64(new Uint8Array(ciphertext)), }; - await this.storage.set(storageKey(connectorId), JSON.stringify(envelope)); - return (await this.metadata(connectorId))!; + await this.storage.set( + storageKey(connectorId, owner), + JSON.stringify(envelope), + ); + return (await this.metadata(connectorId, owner))!; } - async delete(connectorId: string): Promise { - await this.storage.delete(storageKey(connectorId)); + async delete(connectorId: string, owner?: string): Promise { + await this.storage.delete(storageKey(connectorId, owner)); } } diff --git a/src/identity.ts b/src/identity.ts new file mode 100644 index 00000000..cc7b288f --- /dev/null +++ b/src/identity.ts @@ -0,0 +1,31 @@ +import type { IdentityReference } from "./types.js"; + +const IDENTITY_PART_RE = /^[\x21-\x7e]{1,256}$/; +const encoder = new TextEncoder(); + +export function validIdentityReference( + value: IdentityReference | undefined, +): value is IdentityReference { + return Boolean( + value && + IDENTITY_PART_RE.test(value.namespace) && + IDENTITY_PART_RE.test(value.id), + ); +} + +function bytesToHex(bytes: Uint8Array): string { + return [...bytes] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +/** Deterministic pseudonymous partition; raw emails and ids do not enter keys. */ +export async function identityStorageKey( + identity: IdentityReference, +): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + encoder.encode(`${identity.namespace}\n${identity.id}`), + ); + return bytesToHex(new Uint8Array(digest)); +} diff --git a/src/index.ts b/src/index.ts index 468623a8..9fcd48eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,9 +17,11 @@ import { } from "./executor-admission.js"; import type { ActivityReadGate, ActivityStore } from "./activity.js"; import type { + AuthenticatedIdentity, Connector, ConnectaBranding, Executor, + IdentityReference, InboundAuth, KVStorage, Logger, @@ -150,10 +152,29 @@ export interface ConnectaAdmissionConfig { code?: AdmissionPoolConfig; } +/** Config-owned identity rules for one deployment and tenant. */ +export interface ConnectaIdentityConfig { + /** Connector ids this admitted identity may discover and call. */ + connectorAccess?( + identity: Readonly, + ): "all" | readonly string[] | Promise<"all" | readonly string[]>; + /** + * Whether a human may manage deployment access tokens and global activity. + * Connector access already permits that human to manage the visible + * connector's shared or personal auth. Omit to preserve the existing + * all-interactive-humans operator rule. + */ + operatorAccess?( + principal: Readonly, + ): boolean | Promise; +} + export interface ConnectaConfig { connectors: Connector[]; /** Inbound auth adapters. Includes bearerToken(...); omit for open (dev). */ auth?: InboundAuth | InboundAuth[]; + /** Identity-derived connector visibility and deployment operator membership. */ + identity?: ConnectaIdentityConfig; /** KVStorage impl. Defaults to memoryStorage(). */ storage?: KVStorage; /** @@ -273,6 +294,10 @@ const admissionPoolSchema = { const CONFIG_SCHEMA = { connectors: null, auth: null, + identity: { + connectorAccess: null, + operatorAccess: null, + } satisfies ClosedOptionSchema, storage: null, publicUrl: null, activity: { @@ -520,7 +545,7 @@ export function createConnecta(config: ConnectaConfig): Connecta { const encryptionKey = config.credentials?.encryptionKey; if (credentialConnectors.length > 0 && !encryptionKey) { logger.warn( - "Operator-managed credentials are unavailable because " + + "Human-managed credentials are unavailable because " + "credentials.encryptionKey is not configured for connectors: " + credentialConnectors.map((c) => c.id).join(", "), ); @@ -594,6 +619,7 @@ export function createConnecta(config: ConnectaConfig): Connecta { const handler = createFetchHandler({ registry, auth: inboundAuth, + identity: config.identity, publicUrl: config.publicUrl, serverInfo, logger, @@ -690,6 +716,8 @@ export type { InboundAuthRuntimeContext, UiAuthConfig, AuthResult, + AuthenticatedIdentity, + IdentityReference, JsonSchema, KVStorage, Logger, diff --git a/src/meta-tools.ts b/src/meta-tools.ts index f8b0ed84..bb0e27a0 100644 --- a/src/meta-tools.ts +++ b/src/meta-tools.ts @@ -737,8 +737,10 @@ export function createMetaTools( operatorUrl: new URL("/credentials", baseUrl).toString(), instructions: "Have the operator open operatorUrl, set and test the credential, " + - "then retry the original call. No redeploy is needed. Credential " + - "mutation requires a Clerk-authenticated operator.", + "then retry the original call. No redeploy is needed. " + + (connector.authScope === "personal" + ? "Credential mutation requires the signed-in principal who owns this connection." + : "Shared credential mutation requires a signed-in human with access to this connector."), }); } const ctx = registry.contextFor(connector.id, baseUrl, requestScope); @@ -747,6 +749,12 @@ export function createMetaTools( ctx, args.force !== undefined ? { force: args.force } : {}, ); + if (status.authorizationUrl) { + await registry.bindOAuthHandoff( + connector.id, + status.authorizationUrl, + ); + } if (status.state === "auth_required" && !status.authorizationUrl) { // auth_required with nothing to open is a dead end for the operator. return errorResult( diff --git a/src/operator-ui/app/connections.tsx b/src/operator-ui/app/connections.tsx index 18b3eaec..bf55d081 100644 --- a/src/operator-ui/app/connections.tsx +++ b/src/operator-ui/app/connections.tsx @@ -83,6 +83,10 @@ function ConnectorCard({ {toolCountLabel(connector.toolCount)}
{connector.id} +
+ + {connector.authScope === "personal" ? "personal auth" : "shared auth"} + {connector.message ? ( diff --git a/src/operator-ui/app/credentials.tsx b/src/operator-ui/app/credentials.tsx index 239d3831..26a6e55a 100644 --- a/src/operator-ui/app/credentials.tsx +++ b/src/operator-ui/app/credentials.tsx @@ -147,7 +147,7 @@ function CredentialCard({ {credentialStateLabel(credential)}

- {connector.id} · {credential.label} + {connector.id} · {connector.authScope === "personal" ? "personal" : "shared"} · {credential.label}

{credential.description ? (

{credential.description}

@@ -232,7 +232,7 @@ export function CredentialsPage({ state }: { state: OperatorState }) {

- Rotate operator-managed connector credentials. Stored values are + Manage shared or personal connector credentials. Stored values are never returned or displayed.

diff --git a/src/operator-ui/generated.ts b/src/operator-ui/generated.ts index 65dd9295..f37427cc 100644 --- a/src/operator-ui/generated.ts +++ b/src/operator-ui/generated.ts @@ -1,4 +1,4 @@ // Generated by scripts/build-operator-ui.mjs. Do not edit. // Source: src/operator-ui/app/main.tsx and src/operator-ui/browser.css. export const OPERATOR_UI_CSS: string = "/* src/operator-ui/browser.css */\n:root {\n color-scheme: light;\n --ink: #000;\n --paper: #fff;\n --rule: #ccc;\n --muted: #666;\n --trace: #f5f5f5;\n --shell: 70rem;\n --pad: 1rem;\n --gap: 1.5rem;\n --sans:\n \"Helvetica Neue\",\n Helvetica,\n Arial,\n sans-serif;\n --mono:\n ui-monospace,\n \"SF Mono\",\n Menlo,\n Monaco,\n \"Cascadia Code\",\n Consolas,\n monospace;\n}\n* {\n border-radius: 0;\n box-sizing: border-box;\n}\nhtml {\n background: var(--paper);\n color: var(--ink);\n font-family: var(--sans);\n font-size: 16px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n}\nbody {\n margin: 0;\n min-height: 100vh;\n}\n::selection {\n background: var(--ink);\n color: var(--paper);\n}\n:is(h1, h2, h3, p, ul, ol) {\n margin: 0;\n padding: 0;\n}\n:is(h1, h2, h3) {\n font-size: inherit;\n font-weight: 400;\n}\n:is(ul, ol) {\n list-style: none;\n}\na {\n color: inherit;\n}\nbutton,\ninput {\n font: inherit;\n}\nbutton {\n background: none;\n border: 0;\n color: inherit;\n cursor: pointer;\n margin: 0;\n padding: 0;\n text-align: left;\n}\nbutton:disabled {\n cursor: wait;\n opacity: .5;\n}\ninput {\n background: var(--paper);\n border: 1px solid var(--rule);\n color: var(--ink);\n min-height: 2rem;\n padding: .2rem .5rem;\n}\ninput:focus-visible {\n outline-offset: -1px;\n}\n:is(a, button, input, summary):focus-visible {\n outline: 1px solid var(--ink);\n}\n:is(a, button, summary):focus-visible {\n outline-offset: 2px;\n}\n.skip-link {\n background: var(--paper);\n left: var(--pad);\n padding: .5rem;\n position: fixed;\n top: -4rem;\n z-index: 10;\n}\n.skip-link:focus {\n top: var(--pad);\n}\n.shell {\n margin: 0 auto;\n max-width: var(--shell);\n padding-left: var(--pad);\n padding-right: var(--pad);\n}\n.pgrid {\n column-gap: var(--gap);\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n row-gap: 1rem;\n}\n.pcap {\n grid-column: 1;\n}\n.pbody {\n grid-column: 2 / -1;\n min-width: 0;\n}\n.cap,\n.meta {\n color: var(--muted);\n font-size: .9em;\n}\n.mono {\n font-family: var(--mono);\n font-size: .78rem;\n}\n.visually-hidden {\n clip: rect(0 0 0 0);\n clip-path: inset(50%);\n height: 1px;\n overflow: hidden;\n position: absolute;\n white-space: nowrap;\n width: 1px;\n}\n.masthead {\n align-items: start;\n padding-bottom: var(--pad);\n padding-top: var(--pad);\n}\n.brand {\n font-weight: 500;\n grid-column: 1;\n text-decoration: none;\n}\n.mast-nav {\n display: flex;\n gap: var(--gap);\n grid-column: 2 / -1;\n justify-content: space-between;\n min-width: 0;\n}\n.mast-actions {\n display: flex;\n gap: var(--gap);\n justify-content: flex-end;\n min-width: 0;\n}\n.page-nav,\n.session-actions {\n display: flex;\n flex-wrap: wrap;\n gap: .5rem var(--gap);\n}\n.mast-actions :is(a, button) {\n align-items: center;\n display: inline-flex;\n min-height: 2rem;\n}\n.navlink,\n.linklike {\n text-decoration: underline;\n text-decoration-thickness: 1.5px;\n text-underline-offset: .22em;\n}\n.navlink {\n text-decoration-color: transparent;\n}\n.navlink:hover,\n.navlink:focus-visible,\n.navlink[aria-current=page] {\n text-decoration-color: currentColor;\n}\n.linklike {\n text-decoration-color: currentColor;\n}\n.linklike:hover,\n.linklike:focus-visible {\n text-decoration-color: transparent;\n}\n.page {\n padding-bottom: 5rem;\n}\n.lead {\n margin-top: 6rem;\n}\n.section,\n.section + .section {\n margin-top: 3rem;\n}\n.lead-copy,\n.body-copy {\n max-width: 34em;\n}\n.lead-copy > * + *,\n.body-copy > * + * {\n margin-top: 1.5rem;\n}\n.row,\n.actions {\n align-items: center;\n display: flex;\n flex-wrap: wrap;\n gap: var(--gap);\n}\n.row input {\n flex: 1;\n min-width: 12rem;\n}\n.gate-actions {\n margin-top: 1.5rem;\n}\n#err {\n margin-top: 1.5rem;\n text-decoration: underline;\n}\n.endpoint {\n border-bottom: 1px solid var(--rule);\n border-top: 1px solid var(--rule);\n}\n.endpoint-row {\n align-items: baseline;\n display: flex;\n gap: var(--gap);\n min-width: 0;\n padding: .75rem 0;\n}\n.endpoint-row code {\n flex: 1;\n min-width: 0;\n overflow-x: auto;\n white-space: nowrap;\n}\n.endpoint-row button {\n flex: none;\n}\n.connector-tools {\n border-bottom: 1px solid var(--rule);\n}\n.toolbar {\n margin-bottom: 1.5rem;\n}\n.toolbar input {\n flex-basis: 18rem;\n}\n#oauthNotice {\n margin-bottom: .75rem;\n}\n#oauthNotice:empty {\n display: none;\n}\n.error-notice,\n.msg {\n text-decoration: underline;\n}\n.card,\n.credential-card,\n.activity-item {\n padding-left: 1.25rem;\n position: relative;\n}\n.card::before,\n.credential-card::before,\n.activity-item::before {\n background: var(--rule);\n bottom: 0;\n content: \"\";\n left: .25rem;\n position: absolute;\n top: 0;\n width: 1px;\n}\n.card {\n border-top: 1px solid var(--rule);\n padding-bottom: .75rem;\n padding-top: .75rem;\n}\n.connector-head {\n display: grid;\n gap: var(--gap);\n grid-template-columns: minmax(0, 2fr) minmax(10rem, 1fr);\n}\n.connector-title {\n align-items: baseline;\n display: flex;\n gap: .5rem;\n}\n.connector-title .dot,\n.activity-stamp .dot {\n margin-left: -1.25rem;\n}\n.activity-stamp {\n align-items: baseline;\n display: flex;\n gap: .75rem;\n}\n.card h2 {\n overflow-wrap: anywhere;\n}\n.connector-state {\n text-align: right;\n}\n.dot {\n background: var(--paper);\n border: 1px solid var(--ink);\n display: inline-block;\n flex: none;\n height: .5rem;\n width: .5rem;\n z-index: 1;\n}\n.dot.ok {\n background: var(--ink);\n}\n.dot.auth_required {\n background:\n linear-gradient(\n 90deg,\n var(--ink) 50%,\n var(--paper) 50%);\n}\n.connector-description {\n margin-top: .25rem;\n max-width: 40rem;\n}\n.connector-message,\n.connector-auth {\n margin-top: .75rem;\n}\n.connector-drift {\n border-top: 1px solid var(--rule);\n margin-top: .75rem;\n padding-top: .75rem;\n}\n.drift-summary {\n margin-top: .25rem;\n}\n.drift-counts {\n display: flex;\n flex-wrap: wrap;\n gap: .25rem var(--gap);\n margin-top: .5rem;\n}\n.drift-count {\n color: var(--muted);\n font-size: .9em;\n}\n.drift-count.flagged {\n color: var(--ink);\n}\n.drift-count-value {\n font-family: var(--mono);\n margin-right: .35rem;\n}\n.drift-count.flagged .drift-count-value {\n text-decoration: underline;\n}\n.credential-ledger {\n border-bottom: 1px solid var(--rule);\n}\n.credential-card {\n border-top: 1px solid var(--rule);\n padding-bottom: .75rem;\n padding-top: .75rem;\n}\n.credential-head {\n align-items: baseline;\n display: flex;\n flex-wrap: wrap;\n gap: .25rem var(--gap);\n justify-content: space-between;\n}\n.credential-copy {\n margin-top: .25rem;\n max-width: 40rem;\n}\n.credential-field-summary {\n border-top: 1px solid var(--rule);\n margin-top: .75rem;\n}\n.credential-field-summary > div {\n border-bottom: 1px solid var(--rule);\n display: flex;\n flex-wrap: wrap;\n gap: .25rem var(--gap);\n justify-content: space-between;\n padding: .5rem 0;\n}\n.credential-actions {\n display: flex;\n flex-wrap: wrap;\n gap: var(--gap);\n margin-top: .75rem;\n}\n.credential-actions button,\n.credential-form button,\n.activity-controls button,\n.activity-more {\n align-items: center;\n display: inline-flex;\n min-height: 2.75rem;\n}\n.credential-form {\n align-items: center;\n display: flex;\n flex-wrap: wrap;\n gap: .75rem var(--gap);\n margin-top: .75rem;\n}\n.credential-form > input {\n flex: 1 1 18rem;\n}\n.credential-fields {\n display: grid;\n flex: 1 1 100%;\n gap: .75rem;\n}\n.credential-field {\n align-items: center;\n display: grid;\n gap: var(--gap);\n grid-template-columns: minmax(9rem, 12rem) 1fr;\n}\n.credential-field input {\n min-width: 0;\n width: 100%;\n}\n.danger {\n text-decoration-style: double;\n}\n.token-create {\n border-bottom: 1px solid var(--rule);\n border-top: 1px solid var(--rule);\n padding: .75rem 0;\n}\n.token-create > label {\n display: block;\n margin-bottom: .5rem;\n}\n.token-create input {\n flex: 1 1 18rem;\n}\n.token-create button,\n.token-card button,\n.token-reveal button {\n align-items: center;\n display: inline-flex;\n min-height: 2.75rem;\n}\n.token-reveal {\n background: var(--ink);\n color: var(--paper);\n margin-top: 1.5rem;\n padding: 1rem 1.25rem;\n}\n.token-reveal .meta,\n.token-reveal .cap {\n color: #bbb;\n}\n.token-reveal-head,\n.token-card-head {\n align-items: baseline;\n display: flex;\n flex-wrap: wrap;\n gap: .25rem var(--gap);\n justify-content: space-between;\n}\n.token-secret {\n border-bottom: 1px solid #555;\n border-top: 1px solid #555;\n margin-top: .75rem;\n}\n.token-secret code {\n color: var(--paper);\n user-select: all;\n}\n.token-ledger {\n border-bottom: 1px solid var(--rule);\n margin-top: 1.5rem;\n}\n.token-card {\n border-top: 1px solid var(--rule);\n padding: .75rem 0 .75rem 1.25rem;\n position: relative;\n}\n.token-card::before {\n background: var(--ink);\n bottom: 0;\n content: \"\";\n left: .25rem;\n position: absolute;\n top: 0;\n width: 1px;\n}\n.token-card.revoked {\n color: var(--muted);\n}\n.token-card.revoked::before {\n background: var(--rule);\n}\ndetails {\n margin-top: .75rem;\n}\nsummary {\n cursor: pointer;\n list-style: none;\n width: max-content;\n}\nsummary::-webkit-details-marker {\n display: none;\n}\n.tool-list {\n border-bottom: 1px solid var(--rule);\n margin-top: .5rem;\n}\n.tool {\n border-top: 1px solid var(--rule);\n display: grid;\n gap: .25rem var(--gap);\n grid-template-columns: minmax(12rem, 1fr) minmax(0, 2fr);\n padding: .5rem 0;\n}\n.tool code {\n font-family: var(--mono);\n font-size: .78rem;\n overflow-wrap: anywhere;\n}\n.tool .td {\n color: var(--muted);\n font-size: .9em;\n}\n.empty {\n border-top: 1px solid var(--rule);\n padding: .75rem 0;\n}\n.activity-copy {\n margin-bottom: 1.5rem;\n}\n.activity-controls {\n margin-bottom: 1.5rem;\n}\n.activity-controls input {\n flex: 1 1 18rem;\n}\n#activityNotice {\n margin-bottom: .75rem;\n}\n.activity-ledger {\n border-bottom: 1px solid var(--rule);\n}\n.activity-item {\n border-top: 1px solid var(--rule);\n display: grid;\n gap: .25rem var(--gap);\n grid-template-columns: minmax(9rem, .85fr) minmax(12rem, 1.4fr) minmax(8rem, .9fr);\n padding-bottom: .75rem;\n padding-top: .75rem;\n}\n.activity-time,\n.activity-actor,\n.activity-detail {\n color: var(--muted);\n font-size: .82rem;\n}\n.activity-actor-id {\n color: var(--muted);\n margin-top: .1rem;\n}\n.activity-address {\n font-family: var(--mono);\n font-size: .78rem;\n overflow-wrap: anywhere;\n}\n.activity-outcome {\n font-size: .9em;\n}\n.activity-item.error .activity-outcome,\n.activity-item.timeout .activity-outcome,\n.activity-item.cancelled .activity-outcome {\n text-decoration: underline;\n}\n.activity-empty {\n border-top: 1px solid var(--rule);\n padding: .75rem 0;\n}\n.activity-more {\n margin-top: .75rem;\n}\n.unavailable {\n background: var(--trace);\n border-bottom: 1px solid var(--rule);\n border-top: 1px solid var(--rule);\n padding: .75rem;\n}\n@media (prefers-reduced-motion: reduce) {\n html:focus-within {\n scroll-behavior: auto;\n }\n}\n@media (max-width: 36.99rem) {\n .pgrid {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n .pcap,\n .pbody {\n grid-column: 1 / -1;\n }\n .masthead .brand {\n grid-column: 1;\n }\n .mast-nav {\n grid-column: 1 / -1;\n grid-row: 2;\n justify-content: flex-start;\n }\n .product {\n display: none;\n }\n .mast-actions {\n align-items: flex-start;\n flex-direction: column;\n font-size: .875rem;\n gap: .25rem;\n }\n .lead {\n margin-top: 4rem;\n }\n .section,\n .section + .section {\n margin-top: 2.5rem;\n }\n .connector-head,\n .tool,\n .activity-item {\n grid-template-columns: 1fr;\n }\n .connector-state {\n text-align: left;\n }\n .credential-field {\n align-items: start;\n grid-template-columns: 1fr;\n gap: .25rem;\n }\n input {\n min-height: 2.75rem;\n }\n}\n"; -export const OPERATOR_UI_SCRIPT: string = "\"use strict\";\n(() => {\n // node_modules/preact/dist/preact.module.js\n var n;\n var l;\n var u;\n var t;\n var i;\n var r;\n var o;\n var e;\n var f;\n var c;\n var a;\n var s;\n var h;\n var p;\n var v;\n var y;\n var d = {};\n var w = [];\n var _ = /acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;\n var g = Array.isArray;\n function m(n2, l3) {\n for (var u4 in l3) n2[u4] = l3[u4];\n return n2;\n }\n function b(n2) {\n n2 && n2.parentNode && n2.parentNode.removeChild(n2);\n }\n function k(l3, u4, t3) {\n var i3, r3, o3, e3 = {};\n for (o3 in u4) \"key\" == o3 ? i3 = u4[o3] : \"ref\" == o3 ? r3 = u4[o3] : e3[o3] = u4[o3];\n if (arguments.length > 2 && (e3.children = arguments.length > 3 ? n.call(arguments, 2) : t3), \"function\" == typeof l3 && null != l3.defaultProps) for (o3 in l3.defaultProps) void 0 === e3[o3] && (e3[o3] = l3.defaultProps[o3]);\n return x(l3, e3, i3, r3, null);\n }\n function x(n2, t3, i3, r3, o3) {\n var e3 = { type: n2, props: t3, key: i3, ref: r3, __k: null, __: null, __b: 0, __e: null, __c: null, constructor: void 0, __v: null == o3 ? ++u : o3, __i: -1, __u: 0 };\n return null == o3 && null != l.vnode && l.vnode(e3), e3;\n }\n function S(n2) {\n return n2.children;\n }\n function C(n2, l3) {\n this.props = n2, this.context = l3;\n }\n function $(n2, l3) {\n if (null == l3) return n2.__ ? $(n2.__, n2.__i + 1) : null;\n for (var u4; l3 < n2.__k.length; l3++) if (null != (u4 = n2.__k[l3]) && null != u4.__e) return u4.__e;\n return \"function\" == typeof n2.type ? $(n2) : null;\n }\n function I(n2) {\n if (n2.__P && n2.__d) {\n var u4 = n2.__v, t3 = u4.__e, i3 = [], r3 = [], o3 = m({}, u4);\n o3.__v = u4.__v + 1, l.vnode && l.vnode(o3), q(n2.__P, o3, u4, n2.__n, n2.__P.namespaceURI, 32 & u4.__u ? [t3] : null, i3, null == t3 ? $(u4) : t3, !!(32 & u4.__u), r3), o3.__v = u4.__v, o3.__.__k[o3.__i] = o3, D(i3, o3, r3), u4.__e = u4.__ = null, o3.__e != t3 && P(o3);\n }\n }\n function P(n2) {\n if (null != (n2 = n2.__) && null != n2.__c) return n2.__e = n2.__c.base = null, n2.__k.some(function(l3) {\n if (null != l3 && null != l3.__e) return n2.__e = n2.__c.base = l3.__e;\n }), P(n2);\n }\n function A(n2) {\n (!n2.__d && (n2.__d = true) && i.push(n2) && !H.__r++ || r != l.debounceRendering) && ((r = l.debounceRendering) || o)(H);\n }\n function H() {\n try {\n for (var n2, l3 = 1; i.length; ) i.length > l3 && i.sort(e), n2 = i.shift(), l3 = i.length, I(n2);\n } finally {\n i.length = H.__r = 0;\n }\n }\n function L(n2, l3, u4, t3, i3, r3, o3, e3, f4, c3, a3) {\n var s3, h3, p3, v3, y3, _3, g2 = t3 && t3.__k || w, m3 = l3.length;\n for (f4 = T(u4, l3, g2, f4, m3), s3 = 0; s3 < m3; s3++) null != (p3 = u4.__k[s3]) && (h3 = -1 != p3.__i && g2[p3.__i] || d, p3.__i = s3, _3 = q(n2, p3, h3, i3, r3, o3, e3, f4, c3, a3), v3 = p3.__e, p3.ref && h3.ref != p3.ref && (h3.ref && J(h3.ref, null, p3), a3.push(p3.ref, p3.__c || v3, p3)), null == y3 && null != v3 && (y3 = v3), 4 & p3.__u ? (f4 = j(p3, f4, n2), h3.__e && (h3.__e = null)) : \"function\" == typeof p3.type && void 0 !== _3 ? f4 = _3 : v3 && (f4 = v3.nextSibling), p3.__u &= -7);\n return u4.__e = y3, f4;\n }\n function T(n2, l3, u4, t3, i3) {\n var r3, o3, e3, f4, c3, a3 = u4.length, s3 = a3, h3 = 0;\n for (n2.__k = new Array(i3), r3 = 0; r3 < i3; r3++) null != (o3 = l3[r3]) && \"boolean\" != typeof o3 && \"function\" != typeof o3 ? (\"string\" == typeof o3 || \"number\" == typeof o3 || \"bigint\" == typeof o3 || o3.constructor == String ? o3 = n2.__k[r3] = x(null, o3, null, null, null) : g(o3) ? o3 = n2.__k[r3] = x(S, { children: o3 }, null, null, null) : void 0 === o3.constructor && o3.__b > 0 ? o3 = n2.__k[r3] = x(o3.type, o3.props, o3.key, o3.ref ? o3.ref : null, o3.__v) : n2.__k[r3] = o3, f4 = r3 + h3, o3.__ = n2, o3.__b = n2.__b + 1, e3 = null, -1 != (c3 = o3.__i = O(o3, u4, f4, s3)) && (s3--, (e3 = u4[c3]) && (e3.__u |= 2)), null == e3 || null == e3.__v ? (-1 == c3 && (i3 > a3 ? h3-- : i3 < a3 && h3++), \"function\" != typeof o3.type && (o3.__u |= 4)) : c3 != f4 && (c3 == f4 - 1 ? h3-- : c3 == f4 + 1 ? h3++ : (c3 > f4 ? h3-- : h3++, o3.__u |= 4))) : n2.__k[r3] = null;\n if (s3) for (r3 = 0; r3 < a3; r3++) null != (e3 = u4[r3]) && 0 == (2 & e3.__u) && (e3.__e == t3 && (t3 = $(e3)), K(e3, e3));\n return t3;\n }\n function j(n2, l3, u4) {\n var t3, i3;\n if (\"function\" == typeof n2.type) {\n for (t3 = n2.__k, i3 = 0; t3 && i3 < t3.length; i3++) t3[i3] && (t3[i3].__ = n2, l3 = j(t3[i3], l3, u4));\n return l3;\n }\n n2.__e != l3 && (l3 && n2.type && !l3.parentNode && (l3 = $(n2)), l3 = u4.insertBefore(n2.__e, l3 || null));\n do {\n l3 = l3 && l3.nextSibling;\n } while (null != l3 && 8 == l3.nodeType);\n return l3;\n }\n function O(n2, l3, u4, t3) {\n var i3, r3, o3, e3 = n2.key, f4 = n2.type, c3 = l3[u4], a3 = null != c3 && 0 == (2 & c3.__u);\n if (null === c3 && null == e3 || a3 && e3 == c3.key && f4 == c3.type) return u4;\n if (t3 > (a3 ? 1 : 0)) {\n for (i3 = u4 - 1, r3 = u4 + 1; i3 >= 0 || r3 < l3.length; ) if (null != (c3 = l3[o3 = i3 >= 0 ? i3-- : r3++]) && 0 == (2 & c3.__u) && e3 == c3.key && f4 == c3.type) return o3;\n }\n return -1;\n }\n function z(n2, l3, u4) {\n \"-\" == l3[0] ? n2.setProperty(l3, null == u4 ? \"\" : u4) : n2[l3] = null == u4 ? \"\" : \"number\" != typeof u4 || _.test(l3) ? u4 : u4 + \"px\";\n }\n function N(n2, l3, u4, t3, i3) {\n var r3, o3;\n n: if (\"style\" == l3) if (\"string\" == typeof u4) n2.style.cssText = u4;\n else {\n if (\"string\" == typeof t3 && (n2.style.cssText = t3 = \"\"), t3) for (l3 in t3) u4 && l3 in u4 || z(n2.style, l3, \"\");\n if (u4) for (l3 in u4) t3 && u4[l3] == t3[l3] || z(n2.style, l3, u4[l3]);\n }\n else if (\"o\" == l3[0] && \"n\" == l3[1]) r3 = l3 != (l3 = l3.replace(s, \"$1\")), o3 = l3.toLowerCase(), l3 = o3 in n2 || \"onFocusOut\" == l3 || \"onFocusIn\" == l3 ? o3.slice(2) : l3.slice(2), n2.l || (n2.l = {}), n2.l[l3 + r3] = u4, u4 ? t3 ? u4[a] = t3[a] : (u4[a] = h, n2.addEventListener(l3, r3 ? v : p, r3)) : n2.removeEventListener(l3, r3 ? v : p, r3);\n else {\n if (\"http://www.w3.org/2000/svg\" == i3) l3 = l3.replace(/xlink(H|:h)/, \"h\").replace(/sName$/, \"s\");\n else if (\"width\" != l3 && \"height\" != l3 && \"href\" != l3 && \"list\" != l3 && \"form\" != l3 && \"tabIndex\" != l3 && \"download\" != l3 && \"rowSpan\" != l3 && \"colSpan\" != l3 && \"role\" != l3 && \"popover\" != l3 && l3 in n2) try {\n n2[l3] = null == u4 ? \"\" : u4;\n break n;\n } catch (n3) {\n }\n \"function\" == typeof u4 || (null == u4 || false === u4 && \"-\" != l3[4] ? n2.removeAttribute(l3) : n2.setAttribute(l3, \"popover\" == l3 && 1 == u4 ? \"\" : u4));\n }\n }\n function V(n2) {\n return function(u4) {\n if (this.l) {\n var t3 = this.l[u4.type + n2];\n if (null == u4[c]) u4[c] = h++;\n else if (u4[c] < t3[a]) return;\n return t3(l.event ? l.event(u4) : u4);\n }\n };\n }\n function q(n2, u4, t3, i3, r3, o3, e3, f4, c3, a3) {\n var s3, h3, p3, v3, y3, d3, _3, k3, x2, M, I2, P2, A2, H2, T2, j3, F = u4.type;\n if (void 0 !== u4.constructor) return null;\n 128 & t3.__u && (c3 = !!(32 & t3.__u), o3 = [f4 = u4.__e = t3.__e]), (s3 = l.__b) && s3(u4);\n n: if (\"function\" == typeof F) {\n h3 = e3.length;\n try {\n if (x2 = u4.props, M = F.prototype && F.prototype.render, I2 = (s3 = F.contextType) && i3[s3.__c], P2 = s3 ? I2 ? I2.props.value : s3.__ : i3, t3.__c ? k3 = (p3 = u4.__c = t3.__c).__ = p3.__E : (M ? u4.__c = p3 = new F(x2, P2) : (u4.__c = p3 = new C(x2, P2), p3.constructor = F, p3.render = Q), I2 && I2.sub(p3), p3.state || (p3.state = {}), p3.__n = i3, v3 = p3.__d = true, p3.__h = [], p3._sb = []), M && null == p3.__s && (p3.__s = p3.state), M && null != F.getDerivedStateFromProps && (p3.__s == p3.state && (p3.__s = m({}, p3.__s)), m(p3.__s, F.getDerivedStateFromProps(x2, p3.__s))), y3 = p3.props, d3 = p3.state, p3.__v = u4, v3) M && null == F.getDerivedStateFromProps && null != p3.componentWillMount && p3.componentWillMount(), M && null != p3.componentDidMount && p3.__h.push(p3.componentDidMount);\n else {\n if (M && null == F.getDerivedStateFromProps && x2 !== y3 && null != p3.componentWillReceiveProps && p3.componentWillReceiveProps(x2, P2), u4.__v == t3.__v || !p3.__e && null != p3.shouldComponentUpdate && false === p3.shouldComponentUpdate(x2, p3.__s, P2)) {\n u4.__v != t3.__v && (p3.props = x2, p3.state = p3.__s, p3.__d = false), u4.__e = t3.__e, u4.__k = t3.__k, u4.__k.some(function(n3) {\n n3 && (n3.__ = u4);\n }), w.push.apply(p3.__h, p3._sb), p3._sb = [], p3.__h.length && e3.push(p3), f4 = $(t3);\n break n;\n }\n null != p3.componentWillUpdate && p3.componentWillUpdate(x2, p3.__s, P2), M && null != p3.componentDidUpdate && p3.__h.push(function() {\n p3.componentDidUpdate(y3, d3, _3);\n });\n }\n if (p3.context = P2, p3.props = x2, p3.__P = n2, p3.__e = false, A2 = l.__r, H2 = 0, M) p3.state = p3.__s, p3.__d = false, A2 && A2(u4), s3 = p3.render(p3.props, p3.state, p3.context), w.push.apply(p3.__h, p3._sb), p3._sb = [];\n else do {\n p3.__d = false, A2 && A2(u4), s3 = p3.render(p3.props, p3.state, p3.context), p3.state = p3.__s;\n } while (p3.__d && ++H2 < 25);\n p3.state = p3.__s, null != p3.getChildContext && (i3 = m(m({}, i3), p3.getChildContext())), M && !v3 && null != p3.getSnapshotBeforeUpdate && (_3 = p3.getSnapshotBeforeUpdate(y3, d3)), T2 = null != s3 && s3.type === S && null == s3.key ? E(s3.props.children) : s3, f4 = L(n2, g(T2) ? T2 : [T2], u4, t3, i3, r3, o3, e3, f4, c3, a3), p3.base = u4.__e, u4.__u &= -161, p3.__h.length && e3.push(p3), k3 && (p3.__E = p3.__ = null);\n } catch (n3) {\n if (e3.length = h3, u4.__v = null, c3 || null != o3) {\n if (n3.then) {\n for (u4.__u |= c3 ? 160 : 128; f4 && 8 == f4.nodeType && f4.nextSibling; ) f4 = f4.nextSibling;\n null != o3 && (o3[o3.indexOf(f4)] = null), u4.__e = f4;\n } else if (null != o3) for (j3 = o3.length; j3--; ) b(o3[j3]);\n } else u4.__e = t3.__e;\n null == u4.__k && (u4.__k = t3.__k || []), n3.then || B(u4), l.__e(n3, u4, t3);\n }\n } else null == o3 && u4.__v == t3.__v ? (u4.__k = t3.__k, u4.__e = t3.__e) : f4 = u4.__e = G(t3.__e, u4, t3, i3, r3, o3, e3, c3, a3);\n return (s3 = l.diffed) && s3(u4), 128 & u4.__u ? void 0 : f4;\n }\n function B(n2) {\n n2 && (n2.__c && (n2.__c.__e = true), n2.__k && n2.__k.some(B));\n }\n function D(n2, u4, t3) {\n for (var i3 = 0; i3 < t3.length; i3++) J(t3[i3], t3[++i3], t3[++i3]);\n l.__c && l.__c(u4, n2), n2.some(function(u5) {\n try {\n n2 = u5.__h, u5.__h = [], n2.some(function(n3) {\n n3.call(u5);\n });\n } catch (n3) {\n l.__e(n3, u5.__v);\n }\n });\n }\n function E(n2) {\n return \"object\" != typeof n2 || null == n2 || n2.__b > 0 ? n2 : g(n2) ? n2.map(E) : void 0 !== n2.constructor ? null : m({}, n2);\n }\n function G(u4, t3, i3, r3, o3, e3, f4, c3, a3) {\n var s3, h3, p3, v3, y3, w3, _3, m3 = i3.props || d, k3 = t3.props, x2 = t3.type;\n if (\"svg\" == x2 ? o3 = \"http://www.w3.org/2000/svg\" : \"math\" == x2 ? o3 = \"http://www.w3.org/1998/Math/MathML\" : o3 || (o3 = \"http://www.w3.org/1999/xhtml\"), null != e3) {\n for (s3 = 0; s3 < e3.length; s3++) if ((y3 = e3[s3]) && \"setAttribute\" in y3 == !!x2 && (x2 ? y3.localName == x2 : 3 == y3.nodeType)) {\n u4 = y3, e3[s3] = null;\n break;\n }\n }\n if (null == u4) {\n if (null == x2) return document.createTextNode(k3);\n u4 = document.createElementNS(o3, x2, k3.is && k3), c3 && (l.__m && l.__m(t3, e3), c3 = false), e3 = null;\n }\n if (null == x2) m3 === k3 || c3 && u4.data == k3 || (u4.data = k3);\n else {\n if (e3 = \"textarea\" == x2 && null != k3.defaultValue ? null : e3 && n.call(u4.childNodes), !c3 && null != e3) for (m3 = {}, s3 = 0; s3 < u4.attributes.length; s3++) m3[(y3 = u4.attributes[s3]).name] = y3.value;\n for (s3 in m3) y3 = m3[s3], \"dangerouslySetInnerHTML\" == s3 ? p3 = y3 : \"children\" == s3 || s3 in k3 || \"value\" == s3 && \"defaultValue\" in k3 || \"checked\" == s3 && \"defaultChecked\" in k3 || N(u4, s3, null, y3, o3);\n for (s3 in k3) y3 = k3[s3], \"children\" == s3 ? v3 = y3 : \"dangerouslySetInnerHTML\" == s3 ? h3 = y3 : \"value\" == s3 ? w3 = y3 : \"checked\" == s3 ? _3 = y3 : c3 && \"function\" != typeof y3 || m3[s3] === y3 || N(u4, s3, y3, m3[s3], o3);\n if (h3) c3 || p3 && (h3.__html == p3.__html || h3.__html == u4.innerHTML) || (u4.innerHTML = h3.__html), t3.__k = [];\n else if (p3 && (u4.innerHTML = \"\"), L(\"template\" == t3.type ? u4.content : u4, g(v3) ? v3 : [v3], t3, i3, r3, \"foreignObject\" == x2 ? \"http://www.w3.org/1999/xhtml\" : o3, e3, f4, e3 ? e3[0] : i3.__k && $(i3, 0), c3, a3), null != e3) for (s3 = e3.length; s3--; ) b(e3[s3]);\n c3 && \"textarea\" != x2 || (s3 = \"value\", \"progress\" == x2 && null == w3 ? u4.removeAttribute(\"value\") : null != w3 && (w3 !== u4[s3] || \"progress\" == x2 && !w3 || \"option\" == x2 && w3 != m3[s3]) && N(u4, s3, w3, m3[s3], o3), s3 = \"checked\", null != _3 && _3 != u4[s3] && N(u4, s3, _3, m3[s3], o3));\n }\n return u4;\n }\n function J(n2, u4, t3) {\n try {\n if (\"function\" == typeof n2) {\n var i3 = \"function\" == typeof n2.__u;\n i3 && n2.__u(), i3 && null == u4 || (n2.__u = n2(u4));\n } else n2.current = u4;\n } catch (n3) {\n l.__e(n3, t3);\n }\n }\n function K(n2, u4, t3) {\n var i3, r3;\n if (l.unmount && l.unmount(n2), (i3 = n2.ref) && (i3.current && i3.current != n2.__e || J(i3, null, u4)), null != (i3 = n2.__c)) {\n if (i3.componentWillUnmount) try {\n i3.componentWillUnmount();\n } catch (n3) {\n l.__e(n3, u4);\n }\n i3.base = i3.__P = i3.__n = null;\n }\n if (i3 = n2.__k) for (r3 = 0; r3 < i3.length; r3++) i3[r3] && K(i3[r3], u4, t3 || \"function\" != typeof n2.type);\n t3 || b(n2.__e), n2.__c = n2.__ = n2.__e = void 0;\n }\n function Q(n2, l3, u4) {\n return this.constructor(n2, u4);\n }\n function R(u4, t3, i3) {\n var r3, o3, e3, f4;\n t3 == document && (t3 = document.documentElement), l.__ && l.__(u4, t3), o3 = (r3 = \"function\" == typeof i3) ? null : i3 && i3.__k || t3.__k, e3 = [], f4 = [], q(t3, u4 = (!r3 && i3 || t3).__k = k(S, null, [u4]), o3 || d, d, t3.namespaceURI, !r3 && i3 ? [i3] : o3 ? null : t3.firstChild ? n.call(t3.childNodes) : null, e3, !r3 && i3 ? i3 : o3 ? o3.__e : t3.firstChild, r3, f4), D(e3, u4, f4), u4.props.children = null;\n }\n n = w.slice, l = { __e: function(n2, l3, u4, t3) {\n for (var i3, r3, o3; l3 = l3.__; ) if ((i3 = l3.__c) && !i3.__) try {\n if ((r3 = i3.constructor) && null != r3.getDerivedStateFromError && (i3.setState(r3.getDerivedStateFromError(n2)), o3 = i3.__d), null != i3.componentDidCatch && (i3.componentDidCatch(n2, t3 || {}), o3 = i3.__d), o3) return i3.__E = i3;\n } catch (l4) {\n n2 = l4;\n }\n throw n2;\n } }, u = 0, t = function(n2) {\n return null != n2 && void 0 === n2.constructor;\n }, C.prototype.setState = function(n2, l3) {\n var u4;\n u4 = null != this.__s && this.__s != this.state ? this.__s : this.__s = m({}, this.state), \"function\" == typeof n2 && (n2 = n2(m({}, u4), this.props)), n2 && m(u4, n2), null != n2 && this.__v && (l3 && this._sb.push(l3), A(this));\n }, C.prototype.forceUpdate = function(n2) {\n this.__v && (this.__e = true, n2 && this.__h.push(n2), A(this));\n }, C.prototype.render = S, i = [], o = \"function\" == typeof Promise ? Promise.prototype.then.bind(Promise.resolve()) : setTimeout, e = function(n2, l3) {\n return n2.__v.__b - l3.__v.__b;\n }, H.__r = 0, f = Math.random().toString(8), c = \"__d\" + f, a = \"__a\" + f, s = /(PointerCapture)$|Capture$/i, h = 0, p = V(false), v = V(true), y = 0;\n\n // node_modules/preact/hooks/dist/hooks.module.js\n var t2;\n var r2;\n var u2;\n var i2;\n var o2 = 0;\n var f2 = [];\n var c2 = l;\n var e2 = c2.__b;\n var a2 = c2.__r;\n var v2 = c2.diffed;\n var l2 = c2.__c;\n var m2 = c2.unmount;\n var p2 = c2.__;\n function s2(n2, t3) {\n c2.__h && c2.__h(r2, n2, o2 || t3), o2 = 0;\n var u4 = r2.__H || (r2.__H = { __: [], __h: [] });\n return n2 >= u4.__.length && u4.__.push({}), u4.__[n2];\n }\n function d2(n2) {\n return o2 = 1, y2(D2, n2);\n }\n function y2(n2, u4, i3) {\n var o3 = s2(t2++, 2);\n if (o3.t = n2, !o3.__c && (o3.__ = [i3 ? i3(u4) : D2(void 0, u4), function(n3) {\n var t3 = o3.__N ? o3.__N[0] : o3.__[0], r3 = o3.t(t3, n3);\n t3 !== r3 && (o3.__N = [r3, o3.__[1]], o3.__c.setState({}));\n }], o3.__c = r2, !r2.__f)) {\n var f4 = function(n3, t3, r3) {\n if (!o3.__c.__H) return true;\n var u5 = false, i4 = o3.__c.props !== n3;\n if (o3.__c.__H.__.some(function(n4) {\n if (n4.__N) {\n u5 = true;\n var t4 = n4.__[0];\n n4.__ = n4.__N, n4.__N = void 0, t4 !== n4.__[0] && (i4 = true);\n }\n }), c3) {\n var f5 = c3.call(this, n3, t3, r3);\n return u5 ? f5 || i4 : f5;\n }\n return !u5 || i4;\n };\n r2.__f = true;\n var c3 = r2.shouldComponentUpdate, e3 = r2.componentWillUpdate;\n r2.componentWillUpdate = function(n3, t3, r3) {\n if (this.__e) {\n var u5 = c3;\n c3 = void 0, f4(n3, t3, r3), c3 = u5;\n }\n e3 && e3.call(this, n3, t3, r3);\n }, r2.shouldComponentUpdate = f4;\n }\n return o3.__N || o3.__;\n }\n function h2(n2, u4) {\n var i3 = s2(t2++, 3);\n !c2.__s && C2(i3.__H, u4) && (i3.__ = n2, i3.u = u4, r2.__H.__h.push(i3));\n }\n function _2(n2, u4) {\n var i3 = s2(t2++, 4);\n !c2.__s && C2(i3.__H, u4) && (i3.__ = n2, i3.u = u4, r2.__h.push(i3));\n }\n function j2() {\n for (var n2; n2 = f2.shift(); ) {\n var t3 = n2.__H;\n if (n2.__P && t3) try {\n t3.__h.some(z2), t3.__h.some(B2), t3.__h = [];\n } catch (r3) {\n t3.__h = [], c2.__e(r3, n2.__v);\n }\n }\n }\n c2.__b = function(n2) {\n r2 = null, e2 && e2(n2);\n }, c2.__ = function(n2, t3) {\n n2 && t3.__k && t3.__k.__m && (n2.__m = t3.__k.__m), p2 && p2(n2, t3);\n }, c2.__r = function(n2) {\n a2 && a2(n2), t2 = 0;\n var i3 = (r2 = n2.__c).__H;\n i3 && (u2 === r2 ? (i3.__h = [], r2.__h = [], i3.__.some(function(n3) {\n n3.__N && (n3.__ = n3.__N), n3.u = n3.__N = void 0;\n })) : (i3.__h.some(z2), i3.__h.some(B2), i3.__h = [], t2 = 0)), u2 = r2;\n }, c2.diffed = function(n2) {\n v2 && v2(n2);\n var t3 = n2.__c;\n t3 && t3.__H && (t3.__H.__h.length && (1 !== f2.push(t3) && i2 === c2.requestAnimationFrame || ((i2 = c2.requestAnimationFrame) || w2)(j2)), t3.__H.__.some(function(n3) {\n n3.u && (n3.__H = n3.u, n3.u = void 0);\n })), u2 = r2 = null;\n }, c2.__c = function(n2, t3) {\n t3.some(function(n3) {\n try {\n n3.__h.some(z2), n3.__h = n3.__h.filter(function(n4) {\n return !n4.__ || B2(n4);\n });\n } catch (r3) {\n t3.some(function(n4) {\n n4.__h && (n4.__h = []);\n }), t3 = [], c2.__e(r3, n3.__v);\n }\n }), l2 && l2(n2, t3);\n }, c2.unmount = function(n2) {\n m2 && m2(n2);\n var t3, r3 = n2.__c;\n r3 && r3.__H && (r3.__H.__.some(function(n3) {\n try {\n z2(n3);\n } catch (n4) {\n t3 = n4;\n }\n }), r3.__H = void 0, t3 && c2.__e(t3, r3.__v));\n };\n var k2 = \"function\" == typeof requestAnimationFrame;\n function w2(n2) {\n var t3, r3 = function() {\n clearTimeout(u4), k2 && cancelAnimationFrame(t3), setTimeout(n2);\n }, u4 = setTimeout(r3, 35);\n k2 && (t3 = requestAnimationFrame(r3));\n }\n function z2(n2) {\n var t3 = r2, u4 = n2.__c;\n \"function\" == typeof u4 && (n2.__c = void 0, u4()), r2 = t3;\n }\n function B2(n2) {\n var t3 = r2;\n n2.__c = n2.__(), r2 = t3;\n }\n function C2(n2, t3) {\n return !n2 || n2.length !== t3.length || t3.some(function(t4, r3) {\n return t4 !== n2[r3];\n });\n }\n function D2(n2, t3) {\n return \"function\" == typeof t3 ? t3(n2) : t3;\n }\n\n // src/operator-ui/view.ts\n var OPERATOR_PAGES = [\n \"connections\",\n \"credentials\",\n \"tokens\",\n \"activity\"\n ];\n var PAGE_META = {\n connections: { path: \"/\", label: \"Connections\" },\n credentials: { path: \"/credentials\", label: \"Credentials\" },\n tokens: { path: \"/tokens\", label: \"Access tokens\" },\n activity: { path: \"/activity\", label: \"Activity\" }\n };\n function pageForPath(path) {\n const match = OPERATOR_PAGES.find((page) => PAGE_META[page].path === path);\n return match ?? \"connections\";\n }\n function info(message2) {\n return { message: message2, tone: \"info\" };\n }\n function failure(message2) {\n return { message: message2, tone: \"error\" };\n }\n function initialState(page) {\n return {\n page,\n generation: 0,\n session: \"loading\",\n gate: null,\n refreshing: false,\n pendingFocus: null,\n ...identityScopedState()\n };\n }\n function identityScopedState() {\n return {\n data: null,\n connectorFilter: \"\",\n oauthNotice: null,\n oauthBusy: null,\n credentialNotice: null,\n credentialEditing: null,\n credentialBusy: null,\n tokenPhase: \"idle\",\n tokenNotice: null,\n tokens: [],\n createdToken: null,\n tokenRenaming: null,\n tokenBusy: false,\n activityPhase: \"idle\",\n activityNotice: null,\n activityEvents: [],\n activityCursor: null,\n activitySearch: \"\"\n };\n }\n function resetIdentity(state2, gate2 = null) {\n return {\n ...state2,\n generation: state2.generation + 1,\n session: \"gated\",\n gate: gate2,\n refreshing: false,\n pendingFocus: null,\n ...identityScopedState()\n };\n }\n function withPage(state2, page) {\n return {\n ...state2,\n page,\n createdToken: null,\n tokenRenaming: null,\n tokenNotice: null,\n credentialEditing: null,\n credentialNotice: null\n };\n }\n function credentialUnavailableCopy(capability) {\n if (capability === \"no_slots\") {\n return \"No connectors declare operator-managed credential slots. Connector credentials remain configuration-as-code until a slot is declared.\";\n }\n if (capability === \"vault_not_configured\") {\n return \"Credential storage is not configured. Set credentials.encryptionKey before managing connector credentials here.\";\n }\n return \"Credential management requires an eligible interactive operator. Bearer-authenticated sessions can inspect connections but cannot manage stored credentials.\";\n }\n function accessTokenUnavailableCopy(capability) {\n if (capability === \"not_configured\") {\n return \"Access tokens are not configured for this deployment. Add accessTokens to the deployment configuration to enable them.\";\n }\n return \"Access token management requires an eligible interactive operator. A Bearer token can connect to MCP, but it cannot create or revoke other tokens.\";\n }\n function connectorStatusLabel(status) {\n if (status === \"ok\") return \"Connected\";\n if (status === \"auth_required\") return \"Authorization needed\";\n return \"Unavailable\";\n }\n function toolCountLabel(count) {\n return `${count} ${count === 1 ? \"tool\" : \"tools\"}`;\n }\n var DRIFT_CATEGORIES = [\n { key: \"unclassifiedTools\", label: \"Unclassified\" },\n { key: \"unservedTools\", label: \"Unserved\" },\n { key: \"annotationConflicts\", label: \"Annotation conflicts\" },\n { key: \"schemaChanges\", label: \"Schema changes\" }\n ];\n function driftTotal(drift) {\n if (!drift) return 0;\n return DRIFT_CATEGORIES.reduce((sum, { key }) => sum + (drift[key] || 0), 0);\n }\n function driftState(drift) {\n if (!drift) return \"unavailable\";\n return driftTotal(drift) > 0 ? \"warning\" : \"clean\";\n }\n function driftCounts(drift) {\n if (!drift) return [];\n return DRIFT_CATEGORIES.map(({ key, label }) => ({\n key,\n label,\n count: drift[key] || 0\n }));\n }\n function driftSummary(drift) {\n const state2 = driftState(drift);\n if (state2 === \"unavailable\") {\n return \"No catalog refresh observed yet in this runtime.\";\n }\n const observed = formatDate(drift?.observedAt);\n const when = observed ? ` · observed ${observed}` : \"\";\n if (state2 === \"clean\") return `Matches the reviewed manifest${when}`;\n const total = driftTotal(drift);\n return `${total} difference${total === 1 ? \"\" : \"s\"} from the reviewed manifest${when}`;\n }\n function safeHttpHref(url) {\n if (!url) return null;\n try {\n const protocol = new URL(url).protocol;\n return protocol === \"http:\" || protocol === \"https:\" ? url : null;\n } catch {\n return null;\n }\n }\n function formatDate(value) {\n if (!value) return \"\";\n const date = new Date(value);\n return Number.isNaN(date.valueOf()) ? \"\" : date.toLocaleString();\n }\n function actorLabel(actor) {\n if (!actor?.kind) return \"unknown\";\n if (actor.label) return `${actor.kind} · ${actor.label}`;\n return actor.id ? `${actor.kind} · ${actor.id}` : actor.kind;\n }\n function actorStableId(actor) {\n if (!actor?.id) return null;\n if (!actor.label && !actor.namespace) return null;\n return actor.namespace ? `${actor.namespace} · ${actor.id}` : actor.id;\n }\n function activityMatches(event, query) {\n const q2 = query.trim().toLowerCase();\n if (!q2) return true;\n return [\n event.address,\n event.connectorId,\n event.toolName,\n event.source,\n event.outcome,\n event.errorCode,\n event.friction,\n event.actor?.kind,\n event.actor?.id,\n event.actor?.namespace,\n event.actor?.label\n ].some((value) => String(value ?? \"\").toLowerCase().includes(q2));\n }\n function filterActivity(events, query) {\n return events.filter((event) => activityMatches(event, query));\n }\n function activitySummary(events) {\n if (events.length === 0) return \"Arguments and results are never stored.\";\n const tools = new Set(events.map((event) => event.address)).size;\n return `${events.length} loaded call${events.length === 1 ? \"\" : \"s\"} · ${tools} tool${tools === 1 ? \"\" : \"s\"} · no arguments or results stored`;\n }\n var ACTIVITY_OUTCOMES = [\"success\", \"error\", \"timeout\", \"cancelled\"];\n function activityOutcomeClass(outcome) {\n return ACTIVITY_OUTCOMES.includes(outcome) ? outcome : \"error\";\n }\n function activityDetail(event) {\n const parts = [event.source];\n if (event.attempts > 1) parts.push(`${event.attempts} attempts`);\n if (event.friction) parts.push(event.friction);\n if (event.errorCode && event.errorCode !== event.friction) {\n parts.push(event.errorCode);\n }\n return parts.join(\" · \");\n }\n function credentialStateLabel(credential) {\n if (!credential.configured) return \"not configured\";\n const masked = credential.fields?.length ? \"configured\" : `configured · ••••${credential.lastFour ?? \"\"}`;\n return credential.updatedAt ? `${masked} · updated ${formatDate(credential.updatedAt)}` : masked;\n }\n function gateCopy(kind, signedIn) {\n if (kind === \"cloudflare-access\") {\n return \"Cloudflare Access admitted this browser, but the current identity cannot open deployment-wide operator pages.\";\n }\n if (kind !== \"clerk\") {\n return \"Paste an operator bearer token to open this page. Nothing is requested until you do.\";\n }\n return signedIn ? \"Signed in with Clerk, but this account cannot open deployment-wide operator pages.\" : \"Sign in with Clerk to open this operator page.\";\n }\n\n // src/operator-ui/app/config.ts\n var auth = AUTH;\n var mcpUrl = MCP_URL;\n var initialPage = INITIAL_PAGE;\n var titleSuffix = TITLE_SUFFIX;\n var productName = PRODUCT_NAME;\n var productDescription = PRODUCT_DESCRIPTION;\n var productOperatorLabel = PRODUCT_OPERATOR_LABEL;\n var TOKEN_KEY = \"connecta:token\";\n\n // src/operator-ui/app/store.ts\n var state = initialState(initialPage);\n var listeners = /* @__PURE__ */ new Set();\n function getState() {\n return state;\n }\n function subscribe(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n }\n function set(patch) {\n state = { ...state, ...patch };\n for (const listener of listeners) listener();\n }\n function fence() {\n const generation = state.generation;\n return () => generation === state.generation;\n }\n function message(error, fallback) {\n return error instanceof Error && error.message ? error.message : fallback;\n }\n function sessionToken() {\n if (auth.kind === \"cloudflare-access\") return Promise.resolve(void 0);\n return auth.kind === \"clerk\" ? Promise.resolve(window.Clerk?.session?.getToken() ?? null) : Promise.resolve(localStorage.getItem(TOKEN_KEY));\n }\n function requestHeaders(token, body = false) {\n return {\n ...token ? { Authorization: `Bearer ${token}` } : {},\n ...body ? { \"Content-Type\": \"application/json\" } : {}\n };\n }\n function gate(notice = null) {\n state = resetIdentity(state, notice);\n for (const listener of listeners) listener();\n }\n async function operatorRequest(path, method, current, body) {\n const token = await sessionToken();\n if (!current()) throw new Error(\"The operator session changed.\");\n if (!token && auth.kind !== \"cloudflare-access\") {\n throw new Error(\"Your operator session has expired.\");\n }\n const res = await fetch(path, {\n method,\n headers: requestHeaders(token, Boolean(body)),\n credentials: \"same-origin\",\n ...body ? { body: JSON.stringify(body) } : {}\n });\n if (res.status === 204) return null;\n let payload = {};\n try {\n payload = await res.json();\n } catch {\n }\n if (res.status === 401) {\n throw new Error(\"Your operator session was not accepted. Sign in again.\");\n }\n if (res.status === 403) {\n throw new Error(\"This identity may not perform that action.\");\n }\n if (!res.ok) {\n throw new Error(payload.error || `Request failed (${res.status}).`);\n }\n return payload;\n }\n async function loadData() {\n const current = fence();\n if (state.session === \"ready\") set({ refreshing: true });\n let token;\n try {\n token = await sessionToken();\n } catch (error) {\n if (!current()) return;\n const why = message(error, \"unknown error\");\n return gate(failure(`Could not read the Clerk session: ${why}`));\n }\n if (!current()) return;\n if (!token && auth.kind !== \"cloudflare-access\") return gate(null);\n let res;\n try {\n res = await fetch(\"/ui/data\", {\n headers: requestHeaders(token),\n credentials: \"same-origin\"\n });\n } catch (error) {\n if (!current()) return;\n return gate(failure(`Network error: ${message(error, \"unknown error\")}`));\n }\n if (!current()) return;\n if (res.status === 401 || res.status === 403) {\n if (auth.kind === \"clerk\") {\n return gate(\n failure(\n res.status === 403 ? \"This Clerk account is not allowed to access connecta.\" : \"Your Clerk session was not accepted. Sign out and try again.\"\n )\n );\n }\n if (auth.kind === \"cloudflare-access\") {\n return gate(\n failure(\n \"Cloudflare Access admitted the request, but this identity is not an eligible operator.\"\n )\n );\n }\n localStorage.removeItem(TOKEN_KEY);\n return gate(failure(\"Token rejected — enter a valid bearer token.\"));\n }\n if (!res.ok) return gate(failure(`Error ${res.status}`));\n let data;\n try {\n data = await res.json();\n } catch {\n if (!current()) return;\n return gate(failure(\"Operator data could not be read.\"));\n }\n if (!current()) return;\n set({ data, session: \"ready\", gate: null, refreshing: false });\n }\n async function mutate(options) {\n const current = fence();\n set(options.busy);\n try {\n const payload = await options.request(current);\n if (!current()) return;\n if (options.reload) await loadData();\n if (!current()) return;\n set(options.done(payload));\n } catch (error) {\n if (!current()) return;\n if (options.reload) {\n try {\n await loadData();\n } catch {\n }\n if (!current()) return;\n }\n set(options.failed(failure(message(error, options.fallback))));\n }\n }\n function focusHandled() {\n if (state.pendingFocus !== null) set({ pendingFocus: null });\n }\n function setPage(page, focus = false) {\n state = withPage(state, page);\n if (focus) {\n state = {\n ...state,\n pendingFocus: state.session === \"ready\" ? `${page}Heading` : \"gateHeading\"\n };\n }\n for (const listener of listeners) listener();\n }\n function navigate(page, href) {\n history.pushState({ operatorPage: page }, \"\", href);\n setPage(page, true);\n }\n function setConnectorFilter(connectorFilter) {\n set({ connectorFilter });\n }\n function setActivitySearch(activitySearch) {\n set({ activitySearch });\n }\n function signInWithBearer(value) {\n gate(null);\n localStorage.setItem(TOKEN_KEY, value);\n void loadData().then(() => {\n if (state.session === \"ready\") set({ pendingFocus: `${state.page}Heading` });\n });\n }\n function forgetBearer() {\n localStorage.removeItem(TOKEN_KEY);\n gate(null);\n set({ pendingFocus: \"token\" });\n }\n function oauthAction(connector, action) {\n const disconnecting = action === \"disconnect\";\n const confirmed = window.confirm(\n disconnecting ? `Disconnect OAuth for ${connector}? Stored credentials and any pending authorization will be removed.` : `Restart OAuth for ${connector}? Stored credentials and any pending authorization will be replaced.`\n );\n if (!confirmed) return Promise.resolve();\n return mutate({\n request: (current) => operatorRequest(\n `/ui/oauth/${encodeURIComponent(connector)}`,\n disconnecting ? \"DELETE\" : \"POST\",\n current\n ),\n busy: { oauthNotice: null, oauthBusy: connector },\n done: (payload) => ({\n oauthBusy: null,\n pendingFocus: \"oauthNotice\",\n oauthNotice: info(\n disconnecting ? \"OAuth disconnected. Restart authorization when you are ready to reconnect.\" : payload?.message || \"Authorization restarted. Open the authorization link to reconnect.\"\n )\n }),\n failed: (notice) => ({\n oauthBusy: null,\n oauthNotice: notice,\n pendingFocus: \"oauthNotice\"\n }),\n fallback: \"OAuth action failed.\",\n reload: true\n });\n }\n function editCredential(connector) {\n set({ credentialEditing: connector, credentialNotice: null });\n }\n function refuseCredential(copy) {\n set({ credentialNotice: failure(copy), pendingFocus: \"credentialNotice\" });\n }\n function credentialMutation(connector, request, done, reload = true) {\n const land = (credentialNotice) => ({\n credentialBusy: null,\n credentialNotice,\n pendingFocus: \"credentialNotice\"\n });\n return mutate({\n request,\n busy: { credentialBusy: connector, credentialNotice: null },\n done: (payload) => land(done(payload)),\n failed: land,\n fallback: \"Credential action failed.\",\n reload\n });\n }\n function saveCredential(connector, body) {\n return credentialMutation(\n connector,\n (current) => operatorRequest(\n `/ui/credentials/${encodeURIComponent(connector)}`,\n \"PUT\",\n current,\n body\n ),\n () => {\n set({ credentialEditing: null });\n return info(\"Credential saved.\");\n }\n );\n }\n function removeCredential(connector) {\n const confirmed = window.confirm(\n \"Remove this credential? The connector will stop authenticating until a replacement is added.\"\n );\n if (!confirmed) return Promise.resolve();\n return credentialMutation(\n connector,\n (current) => operatorRequest(\n `/ui/credentials/${encodeURIComponent(connector)}`,\n \"DELETE\",\n current\n ),\n () => info(\"Credential removed.\")\n );\n }\n function testCredential(connector) {\n return credentialMutation(\n connector,\n (current) => operatorRequest(\n `/ui/credentials/${encodeURIComponent(connector)}/test`,\n \"POST\",\n current\n ),\n (payload) => {\n const copy = payload?.message || (payload?.ok ? \"Credential is valid.\" : \"Credential test failed.\");\n return payload?.ok ? info(copy) : failure(copy);\n },\n false\n );\n }\n async function loadAccessTokens() {\n const current = fence();\n set({ tokenPhase: \"loading\", tokenNotice: null });\n try {\n const payload = await operatorRequest(\"/ui/access-tokens\", \"GET\", current);\n if (!current()) return;\n set({ tokenPhase: \"ready\", tokens: payload?.accessTokens ?? [] });\n } catch (error) {\n if (!current()) return;\n set({\n tokenPhase: \"error\",\n tokenNotice: failure(\n message(error, \"Access tokens could not be loaded.\")\n )\n });\n }\n }\n function tokenFailure(tokenNotice) {\n return { tokenBusy: false, tokenNotice, pendingFocus: \"tokenNotice\" };\n }\n function createAccessToken(name) {\n if (!name) {\n set(tokenFailure(failure(\"Name the MCP client before creating a token.\")));\n return Promise.resolve(false);\n }\n let created = false;\n return mutate({\n request: (current) => operatorRequest(\"/ui/access-tokens\", \"POST\", current, { name }),\n busy: { tokenBusy: true, tokenNotice: null },\n done: (payload) => {\n const issued = payload?.accessToken;\n if (!payload?.token || !issued) {\n throw new Error(\"The created token was not returned.\");\n }\n created = true;\n return {\n tokenBusy: false,\n tokenPhase: \"ready\",\n tokens: [\n issued,\n ...state.tokens.filter((token) => token.id !== issued.id)\n ],\n createdToken: payload.token,\n tokenNotice: info(\"Access token created.\"),\n pendingFocus: \"tokenRevealHeading\"\n };\n },\n failed: tokenFailure,\n fallback: \"Access token could not be created.\"\n }).then(() => created);\n }\n function dismissCreatedToken() {\n set({ createdToken: null });\n }\n function renameAccessToken(id) {\n set({ tokenRenaming: id });\n }\n function accessTokenMutation(id, method, body, success, fallback) {\n return mutate({\n request: (current) => operatorRequest(\n `/ui/access-tokens/${encodeURIComponent(id)}`,\n method,\n current,\n body\n ),\n busy: { tokenBusy: true, tokenNotice: null },\n done: (payload) => ({\n tokenBusy: false,\n tokenRenaming: null,\n tokenNotice: info(success),\n pendingFocus: \"tokenNotice\",\n ...payload?.accessToken ? {\n tokens: state.tokens.map(\n (token) => token.id === id ? payload.accessToken : token\n )\n } : {}\n }),\n failed: tokenFailure,\n fallback\n });\n }\n function saveAccessTokenName(id, name) {\n return accessTokenMutation(\n id,\n \"PUT\",\n { name },\n \"Access token renamed.\",\n \"Access token could not be renamed.\"\n );\n }\n function revokeAccessToken(id) {\n const named = state.tokens.find((token) => token.id === id);\n const confirmed = window.confirm(\n `Revoke ${named?.name || \"this access token\"}? Its MCP client will immediately lose access.`\n );\n if (!confirmed) return Promise.resolve();\n return accessTokenMutation(\n id,\n \"DELETE\",\n void 0,\n \"Access token revoked.\",\n \"Access token could not be revoked.\"\n );\n }\n async function loadActivity(reset) {\n if (!state.data?.activityEnabled) return;\n const current = fence();\n set({\n activityPhase: \"loading\",\n activityNotice: null,\n ...reset ? { activityEvents: [], activityCursor: null } : {}\n });\n const params = new URLSearchParams({ limit: \"50\" });\n if (!reset && state.activityCursor) {\n params.set(\"cursor\", state.activityCursor);\n }\n try {\n const payload = await operatorRequest(\n `/ui/activity?${params}`,\n \"GET\",\n current\n );\n if (!current()) return;\n set({\n activityPhase: \"ready\",\n activityEvents: [\n ...reset ? [] : state.activityEvents,\n ...payload?.events ?? []\n ],\n activityCursor: payload?.nextCursor ?? null\n });\n } catch (error) {\n if (!current()) return;\n set({\n activityPhase: \"error\",\n activityNotice: failure(message(error, \"Activity could not be loaded.\"))\n });\n }\n }\n function signIn() {\n window.Clerk?.redirectToSignIn({\n signInFallbackRedirectUrl: window.location.href,\n signUpFallbackRedirectUrl: window.location.href\n });\n }\n function signOut() {\n if (auth.kind === \"cloudflare-access\") {\n gate(null);\n window.location.assign(\"/cdn-cgi/access/logout\");\n return;\n }\n const clerk = window.Clerk;\n gate(null);\n void clerk?.signOut({ redirectUrl: window.location.href });\n }\n async function boot() {\n const onPop = () => setPage(pageForPath(window.location.pathname), true);\n window.addEventListener(\"popstate\", onPop);\n window.addEventListener(\"pagehide\", dismissCreatedToken);\n if (auth.kind === \"clerk\") {\n const clerk = window.Clerk;\n if (!clerk) {\n const why = \"Clerk could not load. Check your network and try again.\";\n return gate(failure(why));\n }\n try {\n await clerk.load({\n ...auth.signInUrl ? { signInUrl: auth.signInUrl } : {},\n ...auth.signUpUrl ? { signUpUrl: auth.signUpUrl } : {},\n signInFallbackRedirectUrl: window.location.href,\n signUpFallbackRedirectUrl: window.location.href,\n afterSignOutUrl: window.location.href\n });\n let sessionId = clerk.session?.id ?? null;\n clerk.addListener((resources) => {\n const next = resources.session?.id ?? null;\n if (next === sessionId) return;\n sessionId = next;\n gate(null);\n void loadData();\n });\n } catch (error) {\n const why = message(error, \"unknown error\");\n return gate(failure(`Clerk could not initialize: ${why}`));\n }\n }\n await loadData();\n }\n\n // node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js\n var f3 = 0;\n function u3(e3, t3, n2, o3, i3, u4) {\n t3 || (t3 = {});\n var a3, c3, p3 = t3;\n if (\"ref\" in p3) for (c3 in p3 = {}, t3) \"ref\" == c3 ? a3 = t3[c3] : p3[c3] = t3[c3];\n var l3 = { type: e3, props: p3, key: n2, ref: a3, __k: null, __: null, __b: 0, __e: null, __c: null, constructor: void 0, __v: --f3, __i: -1, __u: 0, __source: i3, __self: u4 };\n if (\"function\" == typeof e3 && (a3 = e3.defaultProps)) for (c3 in a3) void 0 === p3[c3] && (p3[c3] = a3[c3]);\n return l.vnode && l.vnode(l3), l3;\n }\n\n // src/operator-ui/app/parts.tsx\n function NoticeLine({\n id,\n notice,\n className = \"meta\"\n }) {\n return /* @__PURE__ */ u3(\n \"p\",\n {\n id,\n class: notice?.tone === \"error\" ? `${className} error-notice` : className,\n role: notice?.tone === \"error\" ? \"alert\" : \"status\",\n \"aria-live\": \"polite\",\n tabIndex: -1,\n children: notice ? notice.message : null\n }\n );\n }\n function Empty({ children }) {\n return /* @__PURE__ */ u3(\"p\", { class: \"empty\", children });\n }\n function Unavailable({ children }) {\n return /* @__PURE__ */ u3(\"div\", { class: \"unavailable\", children });\n }\n function PageLink({\n page,\n class: className,\n current,\n children\n }) {\n const href = PAGE_META[page].path;\n return /* @__PURE__ */ u3(\n \"a\",\n {\n class: className,\n href,\n ...current ? { \"aria-current\": \"page\" } : {},\n onClick: (event) => {\n if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {\n return;\n }\n event.preventDefault();\n navigate(page, href);\n },\n children\n }\n );\n }\n function CopyButton({\n value,\n label,\n class: className = \"linklike\"\n }) {\n const [status, setStatus] = d2(\"idle\");\n h2(() => {\n if (status === \"idle\") return;\n const timer = window.setTimeout(() => setStatus(\"idle\"), 1600);\n return () => window.clearTimeout(timer);\n }, [status]);\n return /* @__PURE__ */ u3(\n \"button\",\n {\n class: className,\n type: \"button\",\n onClick: () => {\n navigator.clipboard.writeText(value).then(\n () => setStatus(\"copied\"),\n () => setStatus(\"failed\")\n );\n },\n children: status === \"copied\" ? \"Copied\" : status === \"failed\" ? \"Copy failed\" : label\n }\n );\n }\n\n // src/operator-ui/app/activity.tsx\n function ActivityRow({ event }) {\n const outcome = activityOutcomeClass(event.outcome);\n const stableId = actorStableId(event.actor);\n return /* @__PURE__ */ u3(\"article\", { class: `activity-item ${outcome}`, children: [\n /* @__PURE__ */ u3(\"div\", { class: \"activity-stamp\", children: [\n /* @__PURE__ */ u3(\n \"span\",\n {\n class: outcome === \"success\" ? \"dot ok\" : \"dot\",\n \"aria-hidden\": \"true\"\n }\n ),\n /* @__PURE__ */ u3(\"div\", { children: [\n /* @__PURE__ */ u3(\"time\", { class: \"activity-time\", dateTime: event.occurredAt, children: formatDate(event.occurredAt) }),\n /* @__PURE__ */ u3(\"div\", { class: \"activity-actor\", children: actorLabel(event.actor) }),\n stableId ? /* @__PURE__ */ u3(\"div\", { class: \"activity-actor-id mono\", children: stableId }) : null\n ] })\n ] }),\n /* @__PURE__ */ u3(\"div\", { children: [\n /* @__PURE__ */ u3(\"div\", { class: \"activity-address\", children: event.address }),\n /* @__PURE__ */ u3(\"div\", { class: \"activity-detail\", children: activityDetail(event) })\n ] }),\n /* @__PURE__ */ u3(\"div\", { children: [\n /* @__PURE__ */ u3(\"div\", { class: \"activity-outcome\", children: event.outcome }),\n /* @__PURE__ */ u3(\"div\", { class: \"activity-detail\", children: [\n event.durationMs,\n \" ms\"\n ] })\n ] })\n ] });\n }\n function ActivityPage({ state: state2 }) {\n const enabled = Boolean(state2.data?.activityEnabled);\n const loading = state2.activityPhase === \"loading\";\n const visible = filterActivity(state2.activityEvents, state2.activitySearch);\n return /* @__PURE__ */ u3(\"section\", { id: \"activityView\", children: /* @__PURE__ */ u3(\"div\", { class: \"lead pgrid\", children: [\n /* @__PURE__ */ u3(\"h1\", { id: \"activityHeading\", class: \"pcap\", tabIndex: -1, children: \"Activity\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"pbody\", children: [\n /* @__PURE__ */ u3(\"p\", { class: \"activity-copy\", id: \"activitySummary\", children: activitySummary(state2.activityEvents) }),\n !enabled ? /* @__PURE__ */ u3(Unavailable, { children: [\n \"Activity history is not configured. Add an\",\n \" \",\n /* @__PURE__ */ u3(\"span\", { class: \"mono\", children: \"activity.store\" }),\n \" with a list reader to enable this page.\"\n ] }) : /* @__PURE__ */ u3(\"div\", { id: \"activityAvailable\", children: [\n /* @__PURE__ */ u3(\"div\", { class: \"row activity-controls\", children: [\n /* @__PURE__ */ u3(\n \"input\",\n {\n id: \"activitySearch\",\n type: \"search\",\n placeholder: \"Search user, tool, or outcome…\",\n \"aria-label\": \"Search loaded activity\",\n value: state2.activitySearch,\n onInput: (event) => setActivitySearch(event.currentTarget.value)\n }\n ),\n /* @__PURE__ */ u3(\n \"button\",\n {\n id: \"refreshActivity\",\n class: \"linklike\",\n type: \"button\",\n disabled: loading,\n onClick: () => void loadActivity(true),\n children: loading ? \"Loading…\" : \"Refresh\"\n }\n )\n ] }),\n /* @__PURE__ */ u3(NoticeLine, { id: \"activityNotice\", notice: state2.activityNotice }),\n /* @__PURE__ */ u3(\n \"div\",\n {\n id: \"activityList\",\n class: \"activity-ledger\",\n \"aria-busy\": loading ? \"true\" : \"false\",\n children: loading && state2.activityEvents.length === 0 ? /* @__PURE__ */ u3(\"div\", { class: \"activity-empty\", children: \"Loading activity…\" }) : state2.activityPhase === \"error\" && state2.activityEvents.length === 0 ? /* @__PURE__ */ u3(\"p\", { class: \"activity-empty\", children: /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n onClick: () => void loadActivity(true),\n children: \"Try loading activity again\"\n }\n ) }) : visible.length === 0 ? /* @__PURE__ */ u3(\"div\", { class: \"activity-empty\", children: state2.activitySearch.trim() ? \"No loaded activity matches this search.\" : \"No connector tool calls recorded yet.\" }) : visible.map((event, index) => /* @__PURE__ */ u3(\n ActivityRow,\n {\n event\n },\n `${event.occurredAt}-${event.address}-${index}`\n ))\n }\n ),\n state2.activityCursor ? /* @__PURE__ */ u3(\n \"button\",\n {\n id: \"moreActivity\",\n class: \"linklike activity-more\",\n type: \"button\",\n disabled: loading,\n onClick: () => void loadActivity(false),\n children: loading ? \"Loading…\" : \"Load older\"\n }\n ) : null\n ] })\n ] })\n ] }) });\n }\n\n // src/operator-ui/model.ts\n function filterUiConnectors(connectors, query) {\n const q2 = query.trim().toLowerCase();\n const filtered = [];\n for (const connector of connectors) {\n const connectorText = [\n connector.id,\n connector.title,\n connector.description,\n connector.status\n ].join(\" \").toLowerCase();\n const connectorMatches = Boolean(q2 && connectorText.includes(q2));\n const tools = connector.tools.filter(\n (tool) => !q2 || connectorMatches || `${tool.name} ${tool.description ?? \"\"}`.toLowerCase().includes(q2)\n );\n if (q2 && tools.length === 0 && !connectorMatches) continue;\n filtered.push({ connector, tools });\n }\n return filtered;\n }\n\n // src/operator-ui/app/connections.tsx\n var DRIFT_HEADING = {\n clean: \"Catalog drift · none\",\n warning: \"Catalog drift · review\",\n unavailable: \"Catalog drift · not observed\"\n };\n function DriftPanel({ connector }) {\n const drift = connector.catalogDrift;\n const state2 = driftState(drift);\n return /* @__PURE__ */ u3(\n \"div\",\n {\n id: `drift-${connector.id}`,\n class: `connector-drift ${state2}`,\n \"data-drift\": state2,\n children: [\n /* @__PURE__ */ u3(\"p\", { class: \"cap\", children: DRIFT_HEADING[state2] }),\n /* @__PURE__ */ u3(\"p\", { class: \"meta drift-summary\", children: driftSummary(drift) }),\n state2 === \"unavailable\" ? null : /* @__PURE__ */ u3(\"ul\", { class: \"drift-counts\", children: driftCounts(drift).map(({ key, label, count }) => /* @__PURE__ */ u3(\"li\", { class: count > 0 ? \"drift-count flagged\" : \"drift-count\", children: [\n /* @__PURE__ */ u3(\"span\", { class: \"drift-count-value\", children: count }),\n /* @__PURE__ */ u3(\"span\", { class: \"drift-count-label\", children: label })\n ] }, key)) })\n ]\n }\n );\n }\n function ConnectorCard({\n connector,\n tools,\n expanded,\n oauthManagement,\n busy\n }) {\n const name = connector.title || connector.id;\n const authorization = safeHttpHref(connector.authorizationUrl);\n return /* @__PURE__ */ u3(\"div\", { class: \"card\", children: [\n /* @__PURE__ */ u3(\"div\", { class: \"connector-head\", children: [\n /* @__PURE__ */ u3(\"div\", { children: [\n /* @__PURE__ */ u3(\"div\", { class: \"connector-title\", children: [\n /* @__PURE__ */ u3(\"span\", { class: `dot ${connector.status}`, \"aria-hidden\": \"true\" }),\n /* @__PURE__ */ u3(\"h2\", { children: name })\n ] }),\n connector.description ? /* @__PURE__ */ u3(\"p\", { class: \"connector-description meta\", children: connector.description }) : null\n ] }),\n /* @__PURE__ */ u3(\"div\", { class: \"connector-state cap\", children: [\n connectorStatusLabel(connector.status),\n \" ·\",\n \" \",\n toolCountLabel(connector.toolCount),\n /* @__PURE__ */ u3(\"br\", {}),\n /* @__PURE__ */ u3(\"span\", { class: \"mono\", children: connector.id })\n ] })\n ] }),\n connector.message ? /* @__PURE__ */ u3(\"p\", { class: \"connector-message msg\", children: connector.message }) : null,\n connector.authorizationUrl ? /* @__PURE__ */ u3(\"p\", { class: authorization ? \"connector-auth\" : \"connector-auth meta\", children: authorization ? /* @__PURE__ */ u3(\n \"a\",\n {\n class: \"linklike\",\n href: authorization,\n target: \"_blank\",\n rel: \"noopener\",\n children: \"Authorize connector →\"\n }\n ) : `Authorization URL: ${connector.authorizationUrl}` }) : null,\n /* @__PURE__ */ u3(DriftPanel, { connector }),\n connector.catalogAccess ? /* @__PURE__ */ u3(\"p\", { class: \"meta\", children: [\n \"Last agent catalog read · \",\n connector.catalogAccess.state,\n \" ·\",\n \" \",\n new Date(connector.catalogAccess.observedAt).toLocaleString()\n ] }) : null,\n connector.oauth && oauthManagement ? /* @__PURE__ */ u3(\"div\", { class: \"credential-actions\", children: [\n /* @__PURE__ */ u3(\n \"button\",\n {\n type: \"button\",\n class: \"linklike danger\",\n \"aria-label\": `Disconnect OAuth for ${name}`,\n disabled: busy,\n onClick: () => void oauthAction(connector.id, \"disconnect\"),\n children: \"Disconnect OAuth\"\n }\n ),\n /* @__PURE__ */ u3(\n \"button\",\n {\n type: \"button\",\n class: \"linklike\",\n \"aria-label\": `${connector.status === \"ok\" ? \"Reconnect OAuth for\" : \"Restart authorization for\"} ${name}`,\n disabled: busy,\n onClick: () => void oauthAction(connector.id, \"reconnect\"),\n children: connector.status === \"ok\" ? \"Reconnect OAuth\" : \"Restart authorization\"\n }\n )\n ] }) : null,\n connector.credential ? /* @__PURE__ */ u3(\"p\", { class: \"connector-auth\", children: /* @__PURE__ */ u3(PageLink, { page: \"credentials\", class: \"linklike\", children: \"Manage credential →\" }) }) : null,\n tools.length ? /* @__PURE__ */ u3(\"details\", { open: expanded, children: [\n /* @__PURE__ */ u3(\"summary\", { class: \"linklike\", children: [\n \"Show tools (\",\n tools.length,\n \")\"\n ] }),\n /* @__PURE__ */ u3(\"div\", { class: \"tool-list\", children: tools.map((tool) => /* @__PURE__ */ u3(\"div\", { class: \"tool\", children: [\n /* @__PURE__ */ u3(\"code\", { children: tool.address }),\n tool.description ? /* @__PURE__ */ u3(\"span\", { class: \"td\", children: tool.description }) : null\n ] }, tool.address)) })\n ] }) : null\n ] });\n }\n function ConnectionsPage({ state: state2 }) {\n const data = state2.data;\n const query = state2.connectorFilter.trim();\n const filtered = data ? filterUiConnectors(data.connectors, query) : [];\n return /* @__PURE__ */ u3(\"section\", { id: \"connectionsView\", children: [\n /* @__PURE__ */ u3(\"div\", { class: \"lead pgrid\", children: [\n /* @__PURE__ */ u3(\"h1\", { id: \"connectionsHeading\", class: \"pcap\", tabIndex: -1, children: \"Connections\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"pbody lead-copy\", children: [\n /* @__PURE__ */ u3(\"p\", { children: \"Use this endpoint to give an MCP client access to the tools below.\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"endpoint\", children: /* @__PURE__ */ u3(\"div\", { class: \"endpoint-row\", children: [\n /* @__PURE__ */ u3(\"code\", { id: \"mcpUrl\", class: \"mono\", children: mcpUrl }),\n /* @__PURE__ */ u3(CopyButton, { value: mcpUrl, label: \"Copy URL\" })\n ] }) }),\n /* @__PURE__ */ u3(\"p\", { class: \"cap\", id: \"serverInfo\", children: data ? `${data.serverInfo?.name || productName} v${data.connectaVersion || \"?\"}` : productOperatorLabel }),\n /* @__PURE__ */ u3(NoticeLine, { id: \"oauthNotice\", notice: state2.oauthNotice })\n ] })\n ] }),\n /* @__PURE__ */ u3(\"section\", { class: \"section pgrid\", \"aria-labelledby\": \"connectorLedgerHeading\", children: [\n /* @__PURE__ */ u3(\"h2\", { class: \"pcap\", id: \"connectorLedgerHeading\", children: \"Connectors\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"pbody\", children: [\n /* @__PURE__ */ u3(\"div\", { class: \"row toolbar\", children: /* @__PURE__ */ u3(\n \"input\",\n {\n id: \"filter\",\n type: \"search\",\n placeholder: \"Filter connectors or tools…\",\n \"aria-label\": \"Filter connectors or tools\",\n value: state2.connectorFilter,\n onInput: (event) => setConnectorFilter(event.currentTarget.value)\n }\n ) }),\n /* @__PURE__ */ u3(\n \"div\",\n {\n id: \"list\",\n class: \"connector-tools\",\n \"aria-busy\": state2.refreshing || !data ? \"true\" : \"false\",\n children: !data ? /* @__PURE__ */ u3(Empty, { children: \"Loading connectors…\" }) : filtered.length === 0 ? /* @__PURE__ */ u3(Empty, { children: query ? \"No connectors or tools match this filter.\" : \"No connectors are declared in this deployment.\" }) : filtered.map(({ connector, tools }) => /* @__PURE__ */ u3(\n ConnectorCard,\n {\n connector,\n tools,\n expanded: Boolean(query),\n oauthManagement: data.oauthManagement,\n busy: state2.oauthBusy === connector.id\n },\n connector.id\n ))\n }\n )\n ] })\n ] })\n ] });\n }\n\n // src/operator-ui/app/credentials.tsx\n function CredentialForm({\n connector,\n credential,\n busy\n }) {\n const fields = credential.fields ?? [];\n const [values, setValues] = d2({});\n const single = fields.length === 0;\n const inputId = `credential-input-${connector}`;\n const submit = () => {\n if (single) {\n const value = (values.value ?? \"\").trim();\n if (!value) return refuseCredential(\"Paste a credential before saving.\");\n return void saveCredential(connector, { value });\n }\n const entries = {};\n for (const field of fields) {\n const value = (values[field.name] ?? \"\").trim();\n if (!value) {\n return refuseCredential(\n \"Complete every credential field before saving.\"\n );\n }\n entries[field.name] = value;\n }\n void saveCredential(connector, { values: entries });\n };\n return /* @__PURE__ */ u3(\"div\", { class: \"credential-form\", \"data-credential-form\": connector, children: [\n single ? /* @__PURE__ */ u3(S, { children: [\n /* @__PURE__ */ u3(\"label\", { class: \"visually-hidden\", for: inputId, children: credential.label }),\n /* @__PURE__ */ u3(\n \"input\",\n {\n id: inputId,\n type: \"password\",\n \"aria-label\": credential.label,\n placeholder: credential.placeholder || \"Paste credential\",\n autocomplete: \"new-password\",\n autocapitalize: \"none\",\n spellcheck: false,\n value: values.value ?? \"\",\n onInput: (event) => setValues({ value: event.currentTarget.value })\n }\n )\n ] }) : /* @__PURE__ */ u3(\"div\", { class: \"credential-fields\", children: fields.map((field, index) => {\n const id = `credential-input-${connector}-${index}`;\n return /* @__PURE__ */ u3(\"div\", { class: \"credential-field\", children: [\n /* @__PURE__ */ u3(\"label\", { for: id, children: field.label }),\n /* @__PURE__ */ u3(\n \"input\",\n {\n id,\n type: field.inputType || \"password\",\n placeholder: field.placeholder || field.label,\n autocomplete: (field.inputType ?? \"password\") === \"password\" ? \"new-password\" : \"off\",\n autocapitalize: \"none\",\n spellcheck: false,\n value: values[field.name] ?? \"\",\n onInput: (event) => setValues({\n ...values,\n [field.name]: event.currentTarget.value\n })\n }\n )\n ] }, field.name);\n }) }),\n /* @__PURE__ */ u3(\"button\", { class: \"linklike\", type: \"button\", disabled: busy, onClick: submit, children: busy ? \"Saving…\" : \"Save\" }),\n /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n disabled: busy,\n onClick: () => editCredential(null),\n children: \"Cancel\"\n }\n )\n ] });\n }\n function CredentialCard({\n connector,\n credential,\n editing,\n busy\n }) {\n const configured = Boolean(credential.configured);\n const removable = configured || Boolean(credential.removable);\n return /* @__PURE__ */ u3(\n \"section\",\n {\n class: \"credential-card\",\n id: `credential-${connector.id}`,\n \"aria-labelledby\": `credential-title-${connector.id}`,\n children: [\n /* @__PURE__ */ u3(\"div\", { class: \"credential-head\", children: [\n /* @__PURE__ */ u3(\"div\", { class: \"connector-title\", children: [\n /* @__PURE__ */ u3(\n \"span\",\n {\n class: `dot ${configured ? \"ok\" : \"auth_required\"}`,\n \"aria-hidden\": \"true\"\n }\n ),\n /* @__PURE__ */ u3(\"h2\", { id: `credential-title-${connector.id}`, children: connector.title || connector.id })\n ] }),\n /* @__PURE__ */ u3(\"span\", { class: \"credential-state\", children: credentialStateLabel(credential) })\n ] }),\n /* @__PURE__ */ u3(\"p\", { class: \"mono\", children: [\n connector.id,\n \" · \",\n credential.label\n ] }),\n credential.description ? /* @__PURE__ */ u3(\"p\", { class: \"credential-copy meta\", children: credential.description }) : null,\n credential.fields?.length ? /* @__PURE__ */ u3(\"div\", { class: \"credential-field-summary\", children: credential.fields.map((field) => /* @__PURE__ */ u3(\"div\", { children: [\n /* @__PURE__ */ u3(\"span\", { children: field.label }),\n /* @__PURE__ */ u3(\"span\", { class: \"meta\", children: field.configured ? `configured · ••••${field.lastFour ?? \"\"}${field.updatedAt ? ` · updated ${formatDate(field.updatedAt)}` : \"\"}` : \"not configured\" })\n ] }, field.name)) }) : null,\n credential.error ? /* @__PURE__ */ u3(\"div\", { class: \"msg\", children: credential.error }) : null,\n credential.notice ? /* @__PURE__ */ u3(\"p\", { class: \"credential-copy meta\", children: credential.notice }) : null,\n /* @__PURE__ */ u3(\"div\", { class: \"credential-actions\", children: [\n /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n disabled: busy,\n onClick: () => editCredential(editing ? null : connector.id),\n children: removable ? \"Replace\" : \"Add credential\"\n }\n ),\n configured && credential.testable ? /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n disabled: busy,\n onClick: () => void testCredential(connector.id),\n children: busy ? \"Working…\" : \"Test\"\n }\n ) : null,\n removable ? /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike danger\",\n type: \"button\",\n disabled: busy,\n onClick: () => void removeCredential(connector.id),\n children: \"Remove\"\n }\n ) : null\n ] }),\n editing ? /* @__PURE__ */ u3(\n CredentialForm,\n {\n connector: connector.id,\n credential,\n busy\n }\n ) : null\n ]\n }\n );\n }\n function CredentialsPage({ state: state2 }) {\n const data = state2.data;\n const available = data?.credentialManagement === \"available\";\n const slots = (data?.connectors ?? []).filter(\n (connector) => Boolean(connector.credential)\n );\n return /* @__PURE__ */ u3(\"section\", { id: \"credentialsView\", children: /* @__PURE__ */ u3(\"div\", { class: \"lead pgrid\", children: [\n /* @__PURE__ */ u3(\"h1\", { id: \"credentialsHeading\", class: \"pcap\", tabIndex: -1, children: \"Credentials\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"pbody\", children: [\n /* @__PURE__ */ u3(\"p\", { class: \"activity-copy\", children: \"Rotate operator-managed connector credentials. Stored values are never returned or displayed.\" }),\n /* @__PURE__ */ u3(NoticeLine, { id: \"credentialNotice\", notice: state2.credentialNotice }),\n !available ? /* @__PURE__ */ u3(Unavailable, { children: credentialUnavailableCopy(data?.credentialManagement) }) : /* @__PURE__ */ u3(\n \"div\",\n {\n id: \"credentialList\",\n class: \"credential-ledger\",\n \"aria-busy\": state2.credentialBusy ? \"true\" : \"false\",\n children: slots.length === 0 ? /* @__PURE__ */ u3(Empty, { children: \"No connector in this deployment declares a credential slot yet.\" }) : slots.map((connector) => /* @__PURE__ */ u3(\n CredentialCard,\n {\n connector,\n credential: connector.credential,\n editing: state2.credentialEditing === connector.id,\n busy: state2.credentialBusy === connector.id\n },\n connector.id\n ))\n }\n )\n ] })\n ] }) });\n }\n\n // src/operator-ui/app/tokens.tsx\n function CreateForm({ busy }) {\n const [name, setName] = d2(\"\");\n return /* @__PURE__ */ u3(\n \"form\",\n {\n id: \"tokenCreateForm\",\n class: \"token-create\",\n onSubmit: (event) => {\n event.preventDefault();\n void createAccessToken(name.trim()).then((created) => {\n if (created) setName(\"\");\n });\n },\n children: [\n /* @__PURE__ */ u3(\"label\", { for: \"tokenName\", children: \"Client name\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"row\", children: [\n /* @__PURE__ */ u3(\n \"input\",\n {\n id: \"tokenName\",\n type: \"text\",\n maxLength: 80,\n placeholder: \"Claude desktop, ChatGPT production…\",\n autocomplete: \"off\",\n value: name,\n onInput: (event) => setName(event.currentTarget.value)\n }\n ),\n /* @__PURE__ */ u3(\"button\", { id: \"createToken\", class: \"linklike\", type: \"submit\", disabled: busy, children: busy ? \"Creating…\" : \"Create token\" })\n ] })\n ]\n }\n );\n }\n function Reveal({ token }) {\n return /* @__PURE__ */ u3(\n \"section\",\n {\n id: \"tokenReveal\",\n class: \"token-reveal\",\n \"aria-labelledby\": \"tokenRevealHeading\",\n children: [\n /* @__PURE__ */ u3(\"div\", { class: \"token-reveal-head\", children: [\n /* @__PURE__ */ u3(\"h2\", { id: \"tokenRevealHeading\", tabIndex: -1, children: \"Copy this token now\" }),\n /* @__PURE__ */ u3(\"span\", { class: \"cap\", children: \"Shown once\" })\n ] }),\n /* @__PURE__ */ u3(\"p\", { class: \"meta\", children: \"Store it in the MCP client before leaving this page. It cannot be displayed again.\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"endpoint-row token-secret\", children: [\n /* @__PURE__ */ u3(\"code\", { id: \"createdToken\", class: \"mono\", children: token }),\n /* @__PURE__ */ u3(CopyButton, { value: token, label: \"Copy token\" })\n ] }),\n /* @__PURE__ */ u3(\"button\", { class: \"linklike\", type: \"button\", onClick: dismissCreatedToken, children: \"I stored it\" })\n ]\n }\n );\n }\n function TokenCard({\n token,\n renaming,\n busy\n }) {\n const [name, setName] = d2(token.name);\n const revoked = Boolean(token.revokedAt);\n return /* @__PURE__ */ u3(\n \"section\",\n {\n class: revoked ? \"token-card revoked\" : \"token-card\",\n \"aria-labelledby\": `access-token-${token.id}`,\n children: [\n /* @__PURE__ */ u3(\"div\", { class: \"token-card-head\", children: [\n /* @__PURE__ */ u3(\"div\", { children: [\n /* @__PURE__ */ u3(\"h2\", { id: `access-token-${token.id}`, children: token.name }),\n /* @__PURE__ */ u3(\"p\", { class: \"mono\", children: [\n token.tokenPrefix,\n \"…\"\n ] })\n ] }),\n /* @__PURE__ */ u3(\"div\", { class: \"cap\", children: revoked ? `Revoked ${formatDate(token.revokedAt)}` : `Created ${formatDate(token.createdAt)}` })\n ] }),\n /* @__PURE__ */ u3(\"div\", { class: \"credential-actions\", children: [\n /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n disabled: busy,\n onClick: () => {\n setName(token.name);\n renameAccessToken(renaming ? null : token.id);\n },\n children: \"Rename\"\n }\n ),\n revoked ? null : /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike danger\",\n type: \"button\",\n disabled: busy,\n onClick: () => void revokeAccessToken(token.id),\n children: \"Revoke\"\n }\n )\n ] }),\n renaming ? /* @__PURE__ */ u3(\n \"form\",\n {\n class: \"credential-form\",\n onSubmit: (event) => {\n event.preventDefault();\n const next = name.trim();\n if (next) void saveAccessTokenName(token.id, next);\n },\n children: [\n /* @__PURE__ */ u3(\"label\", { class: \"visually-hidden\", for: `token-name-${token.id}`, children: \"Token name\" }),\n /* @__PURE__ */ u3(\n \"input\",\n {\n id: `token-name-${token.id}`,\n type: \"text\",\n maxLength: 80,\n autocomplete: \"off\",\n value: name,\n onInput: (event) => setName(event.currentTarget.value)\n }\n ),\n /* @__PURE__ */ u3(\"button\", { class: \"linklike\", type: \"submit\", disabled: busy, children: \"Save name\" }),\n /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n disabled: busy,\n onClick: () => renameAccessToken(null),\n children: \"Cancel\"\n }\n )\n ]\n }\n ) : null\n ]\n }\n );\n }\n function TokensPage({ state: state2 }) {\n const available = state2.data?.accessTokenManagement === \"available\";\n return /* @__PURE__ */ u3(\"section\", { id: \"tokensView\", children: /* @__PURE__ */ u3(\"div\", { class: \"lead pgrid\", children: [\n /* @__PURE__ */ u3(\"h1\", { id: \"tokensHeading\", class: \"pcap\", tabIndex: -1, children: \"Access tokens\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"pbody\", children: [\n /* @__PURE__ */ u3(\"p\", { class: \"activity-copy\", children: \"Create named Bearer tokens for MCP clients. Each secret is shown once; revoke it when that client should lose access.\" }),\n /* @__PURE__ */ u3(NoticeLine, { id: \"tokenNotice\", notice: state2.tokenNotice }),\n !available ? /* @__PURE__ */ u3(Unavailable, { children: accessTokenUnavailableCopy(state2.data?.accessTokenManagement) }) : /* @__PURE__ */ u3(\"div\", { id: \"tokenAvailable\", children: [\n state2.createdToken ? /* @__PURE__ */ u3(Reveal, { token: state2.createdToken }) : /* @__PURE__ */ u3(CreateForm, { busy: state2.tokenBusy }),\n /* @__PURE__ */ u3(\n \"div\",\n {\n id: \"tokenList\",\n class: \"token-ledger\",\n \"aria-busy\": state2.tokenPhase === \"loading\" ? \"true\" : \"false\",\n children: state2.tokenPhase === \"loading\" ? /* @__PURE__ */ u3(Empty, { children: \"Loading access tokens…\" }) : state2.tokenPhase === \"error\" ? /* @__PURE__ */ u3(\"p\", { class: \"empty\", children: /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n onClick: () => void loadAccessTokens(),\n children: \"Try loading access tokens again\"\n }\n ) }) : state2.tokens.length === 0 ? /* @__PURE__ */ u3(Empty, { children: \"No access tokens yet. Name the first MCP client above.\" }) : state2.tokens.map((token) => /* @__PURE__ */ u3(\n TokenCard,\n {\n token,\n renaming: state2.tokenRenaming === token.id,\n busy: state2.tokenBusy\n },\n token.id\n ))\n }\n )\n ] })\n ] })\n ] }) });\n }\n\n // src/operator-ui/app/main.tsx\n function useOperatorState() {\n const [, bump] = y2((count) => count + 1, 0);\n const snapshot = getState();\n _2(() => {\n const unsubscribe = subscribe(() => bump(void 0));\n if (getState() !== snapshot) bump(void 0);\n return unsubscribe;\n }, []);\n return snapshot;\n }\n function visiblePages(state2) {\n return OPERATOR_PAGES.filter((page) => {\n if (page === \"credentials\") {\n return state2.data?.credentialManagement === \"available\";\n }\n if (page === \"tokens\") {\n return state2.data?.accessTokenManagement === \"available\";\n }\n if (page === \"activity\") return Boolean(state2.data?.activityEnabled);\n return true;\n });\n }\n function OperatorNav() {\n const state2 = useOperatorState();\n if (state2.session !== \"ready\") return null;\n return /* @__PURE__ */ u3(\"div\", { class: \"mast-actions\", children: [\n /* @__PURE__ */ u3(\"nav\", { class: \"page-nav\", \"aria-label\": \"Operator pages\", children: visiblePages(state2).map((page) => /* @__PURE__ */ u3(\n PageLink,\n {\n page,\n class: \"navlink\",\n current: state2.page === page,\n children: PAGE_META[page].label\n },\n page\n )) }),\n /* @__PURE__ */ u3(\"div\", { class: \"session-actions\", \"aria-label\": \"Session actions\", children: auth.kind === \"clerk\" || auth.kind === \"cloudflare-access\" ? /* @__PURE__ */ u3(\"button\", { class: \"navlink\", type: \"button\", onClick: signOut, children: \"Sign out\" }) : /* @__PURE__ */ u3(\"button\", { class: \"navlink\", type: \"button\", onClick: forgetBearer, children: \"Change token\" }) })\n ] });\n }\n function Gate({ state: state2 }) {\n const [token, setToken] = d2(\"\");\n const signedIn = auth.kind === \"clerk\" && Boolean(window.Clerk?.user);\n const loading = state2.session === \"loading\";\n return /* @__PURE__ */ u3(\"section\", { id: \"gate\", children: /* @__PURE__ */ u3(\"div\", { class: \"lead pgrid\", children: [\n /* @__PURE__ */ u3(\"h1\", { id: \"gateHeading\", class: \"pcap\", tabIndex: -1, children: PAGE_META[state2.page].label }),\n /* @__PURE__ */ u3(\"div\", { class: \"pbody lead-copy\", children: [\n /* @__PURE__ */ u3(\"p\", { children: productDescription }),\n /* @__PURE__ */ u3(\"p\", { id: \"gateCopy\", class: \"meta\", children: loading ? \"Checking your session…\" : gateCopy(auth.kind, signedIn) }),\n loading ? null : auth.kind === \"clerk\" ? /* @__PURE__ */ u3(\"div\", { id: \"clerkGate\", class: \"actions gate-actions\", children: signedIn ? /* @__PURE__ */ u3(\"button\", { class: \"linklike\", type: \"button\", onClick: signOut, children: \"Sign out\" }) : /* @__PURE__ */ u3(\"button\", { id: \"signin\", class: \"linklike\", type: \"button\", onClick: signIn, children: \"Team sign in\" }) }) : auth.kind === \"cloudflare-access\" ? /* @__PURE__ */ u3(\"div\", { class: \"actions gate-actions\", children: /* @__PURE__ */ u3(\"button\", { class: \"linklike\", type: \"button\", onClick: signOut, children: \"Sign out of Cloudflare Access\" }) }) : /* @__PURE__ */ u3(\n \"form\",\n {\n id: \"tokenGate\",\n class: \"row gate-actions\",\n onSubmit: (event) => {\n event.preventDefault();\n const value = token.trim();\n if (!value) return;\n setToken(\"\");\n signInWithBearer(value);\n },\n children: [\n /* @__PURE__ */ u3(\n \"input\",\n {\n id: \"token\",\n type: \"password\",\n placeholder: \"Bearer token\",\n autocomplete: \"off\",\n \"aria-label\": \"Bearer token\",\n value: token,\n onInput: (event) => setToken(event.currentTarget.value)\n }\n ),\n /* @__PURE__ */ u3(\"button\", { id: \"save\", class: \"linklike\", type: \"submit\", children: \"Open operator pages\" })\n ]\n }\n ),\n /* @__PURE__ */ u3(NoticeLine, { id: \"err\", notice: state2.gate, className: \"\" })\n ] })\n ] }) });\n }\n function CurrentPage({ state: state2 }) {\n if (state2.page === \"credentials\") return /* @__PURE__ */ u3(CredentialsPage, { state: state2 });\n if (state2.page === \"tokens\") return /* @__PURE__ */ u3(TokensPage, { state: state2 });\n if (state2.page === \"activity\") return /* @__PURE__ */ u3(ActivityPage, { state: state2 });\n return /* @__PURE__ */ u3(ConnectionsPage, { state: state2 });\n }\n function OperatorApp() {\n const state2 = useOperatorState();\n const ready = state2.session === \"ready\";\n h2(() => {\n document.title = `${PAGE_META[state2.page].label} — ${titleSuffix}`;\n }, [state2.page]);\n h2(() => {\n if (!ready) return;\n if (state2.page === \"tokens\" && state2.data?.accessTokenManagement === \"available\" && state2.tokenPhase === \"idle\") {\n void loadAccessTokens();\n }\n if (state2.page === \"activity\" && state2.data?.activityEnabled && state2.activityPhase === \"idle\") {\n void loadActivity(true);\n }\n });\n h2(() => {\n if (!state2.pendingFocus) return;\n document.getElementById(state2.pendingFocus)?.focus();\n focusHandled();\n }, [state2.pendingFocus]);\n return ready ? /* @__PURE__ */ u3(\"div\", { id: \"app\", children: /* @__PURE__ */ u3(CurrentPage, { state: state2 }) }) : /* @__PURE__ */ u3(Gate, { state: state2 });\n }\n function mount(id, view) {\n const host = document.getElementById(id);\n if (!host) return;\n host.textContent = \"\";\n R(view, host);\n }\n mount(\"operatorNav\", /* @__PURE__ */ u3(OperatorNav, {}));\n mount(\"operatorContent\", /* @__PURE__ */ u3(OperatorApp, {}));\n void boot();\n})();\n"; +export const OPERATOR_UI_SCRIPT: string = "\"use strict\";\n(() => {\n // node_modules/preact/dist/preact.module.js\n var n;\n var l;\n var u;\n var t;\n var i;\n var r;\n var o;\n var e;\n var f;\n var c;\n var a;\n var s;\n var h;\n var p;\n var v;\n var y;\n var d = {};\n var w = [];\n var _ = /acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;\n var g = Array.isArray;\n function m(n2, l3) {\n for (var u4 in l3) n2[u4] = l3[u4];\n return n2;\n }\n function b(n2) {\n n2 && n2.parentNode && n2.parentNode.removeChild(n2);\n }\n function k(l3, u4, t3) {\n var i3, r3, o3, e3 = {};\n for (o3 in u4) \"key\" == o3 ? i3 = u4[o3] : \"ref\" == o3 ? r3 = u4[o3] : e3[o3] = u4[o3];\n if (arguments.length > 2 && (e3.children = arguments.length > 3 ? n.call(arguments, 2) : t3), \"function\" == typeof l3 && null != l3.defaultProps) for (o3 in l3.defaultProps) void 0 === e3[o3] && (e3[o3] = l3.defaultProps[o3]);\n return x(l3, e3, i3, r3, null);\n }\n function x(n2, t3, i3, r3, o3) {\n var e3 = { type: n2, props: t3, key: i3, ref: r3, __k: null, __: null, __b: 0, __e: null, __c: null, constructor: void 0, __v: null == o3 ? ++u : o3, __i: -1, __u: 0 };\n return null == o3 && null != l.vnode && l.vnode(e3), e3;\n }\n function S(n2) {\n return n2.children;\n }\n function C(n2, l3) {\n this.props = n2, this.context = l3;\n }\n function $(n2, l3) {\n if (null == l3) return n2.__ ? $(n2.__, n2.__i + 1) : null;\n for (var u4; l3 < n2.__k.length; l3++) if (null != (u4 = n2.__k[l3]) && null != u4.__e) return u4.__e;\n return \"function\" == typeof n2.type ? $(n2) : null;\n }\n function I(n2) {\n if (n2.__P && n2.__d) {\n var u4 = n2.__v, t3 = u4.__e, i3 = [], r3 = [], o3 = m({}, u4);\n o3.__v = u4.__v + 1, l.vnode && l.vnode(o3), q(n2.__P, o3, u4, n2.__n, n2.__P.namespaceURI, 32 & u4.__u ? [t3] : null, i3, null == t3 ? $(u4) : t3, !!(32 & u4.__u), r3), o3.__v = u4.__v, o3.__.__k[o3.__i] = o3, D(i3, o3, r3), u4.__e = u4.__ = null, o3.__e != t3 && P(o3);\n }\n }\n function P(n2) {\n if (null != (n2 = n2.__) && null != n2.__c) return n2.__e = n2.__c.base = null, n2.__k.some(function(l3) {\n if (null != l3 && null != l3.__e) return n2.__e = n2.__c.base = l3.__e;\n }), P(n2);\n }\n function A(n2) {\n (!n2.__d && (n2.__d = true) && i.push(n2) && !H.__r++ || r != l.debounceRendering) && ((r = l.debounceRendering) || o)(H);\n }\n function H() {\n try {\n for (var n2, l3 = 1; i.length; ) i.length > l3 && i.sort(e), n2 = i.shift(), l3 = i.length, I(n2);\n } finally {\n i.length = H.__r = 0;\n }\n }\n function L(n2, l3, u4, t3, i3, r3, o3, e3, f4, c3, a3) {\n var s3, h3, p3, v3, y3, _3, g2 = t3 && t3.__k || w, m3 = l3.length;\n for (f4 = T(u4, l3, g2, f4, m3), s3 = 0; s3 < m3; s3++) null != (p3 = u4.__k[s3]) && (h3 = -1 != p3.__i && g2[p3.__i] || d, p3.__i = s3, _3 = q(n2, p3, h3, i3, r3, o3, e3, f4, c3, a3), v3 = p3.__e, p3.ref && h3.ref != p3.ref && (h3.ref && J(h3.ref, null, p3), a3.push(p3.ref, p3.__c || v3, p3)), null == y3 && null != v3 && (y3 = v3), 4 & p3.__u ? (f4 = j(p3, f4, n2), h3.__e && (h3.__e = null)) : \"function\" == typeof p3.type && void 0 !== _3 ? f4 = _3 : v3 && (f4 = v3.nextSibling), p3.__u &= -7);\n return u4.__e = y3, f4;\n }\n function T(n2, l3, u4, t3, i3) {\n var r3, o3, e3, f4, c3, a3 = u4.length, s3 = a3, h3 = 0;\n for (n2.__k = new Array(i3), r3 = 0; r3 < i3; r3++) null != (o3 = l3[r3]) && \"boolean\" != typeof o3 && \"function\" != typeof o3 ? (\"string\" == typeof o3 || \"number\" == typeof o3 || \"bigint\" == typeof o3 || o3.constructor == String ? o3 = n2.__k[r3] = x(null, o3, null, null, null) : g(o3) ? o3 = n2.__k[r3] = x(S, { children: o3 }, null, null, null) : void 0 === o3.constructor && o3.__b > 0 ? o3 = n2.__k[r3] = x(o3.type, o3.props, o3.key, o3.ref ? o3.ref : null, o3.__v) : n2.__k[r3] = o3, f4 = r3 + h3, o3.__ = n2, o3.__b = n2.__b + 1, e3 = null, -1 != (c3 = o3.__i = O(o3, u4, f4, s3)) && (s3--, (e3 = u4[c3]) && (e3.__u |= 2)), null == e3 || null == e3.__v ? (-1 == c3 && (i3 > a3 ? h3-- : i3 < a3 && h3++), \"function\" != typeof o3.type && (o3.__u |= 4)) : c3 != f4 && (c3 == f4 - 1 ? h3-- : c3 == f4 + 1 ? h3++ : (c3 > f4 ? h3-- : h3++, o3.__u |= 4))) : n2.__k[r3] = null;\n if (s3) for (r3 = 0; r3 < a3; r3++) null != (e3 = u4[r3]) && 0 == (2 & e3.__u) && (e3.__e == t3 && (t3 = $(e3)), K(e3, e3));\n return t3;\n }\n function j(n2, l3, u4) {\n var t3, i3;\n if (\"function\" == typeof n2.type) {\n for (t3 = n2.__k, i3 = 0; t3 && i3 < t3.length; i3++) t3[i3] && (t3[i3].__ = n2, l3 = j(t3[i3], l3, u4));\n return l3;\n }\n n2.__e != l3 && (l3 && n2.type && !l3.parentNode && (l3 = $(n2)), l3 = u4.insertBefore(n2.__e, l3 || null));\n do {\n l3 = l3 && l3.nextSibling;\n } while (null != l3 && 8 == l3.nodeType);\n return l3;\n }\n function O(n2, l3, u4, t3) {\n var i3, r3, o3, e3 = n2.key, f4 = n2.type, c3 = l3[u4], a3 = null != c3 && 0 == (2 & c3.__u);\n if (null === c3 && null == e3 || a3 && e3 == c3.key && f4 == c3.type) return u4;\n if (t3 > (a3 ? 1 : 0)) {\n for (i3 = u4 - 1, r3 = u4 + 1; i3 >= 0 || r3 < l3.length; ) if (null != (c3 = l3[o3 = i3 >= 0 ? i3-- : r3++]) && 0 == (2 & c3.__u) && e3 == c3.key && f4 == c3.type) return o3;\n }\n return -1;\n }\n function z(n2, l3, u4) {\n \"-\" == l3[0] ? n2.setProperty(l3, null == u4 ? \"\" : u4) : n2[l3] = null == u4 ? \"\" : \"number\" != typeof u4 || _.test(l3) ? u4 : u4 + \"px\";\n }\n function N(n2, l3, u4, t3, i3) {\n var r3, o3;\n n: if (\"style\" == l3) if (\"string\" == typeof u4) n2.style.cssText = u4;\n else {\n if (\"string\" == typeof t3 && (n2.style.cssText = t3 = \"\"), t3) for (l3 in t3) u4 && l3 in u4 || z(n2.style, l3, \"\");\n if (u4) for (l3 in u4) t3 && u4[l3] == t3[l3] || z(n2.style, l3, u4[l3]);\n }\n else if (\"o\" == l3[0] && \"n\" == l3[1]) r3 = l3 != (l3 = l3.replace(s, \"$1\")), o3 = l3.toLowerCase(), l3 = o3 in n2 || \"onFocusOut\" == l3 || \"onFocusIn\" == l3 ? o3.slice(2) : l3.slice(2), n2.l || (n2.l = {}), n2.l[l3 + r3] = u4, u4 ? t3 ? u4[a] = t3[a] : (u4[a] = h, n2.addEventListener(l3, r3 ? v : p, r3)) : n2.removeEventListener(l3, r3 ? v : p, r3);\n else {\n if (\"http://www.w3.org/2000/svg\" == i3) l3 = l3.replace(/xlink(H|:h)/, \"h\").replace(/sName$/, \"s\");\n else if (\"width\" != l3 && \"height\" != l3 && \"href\" != l3 && \"list\" != l3 && \"form\" != l3 && \"tabIndex\" != l3 && \"download\" != l3 && \"rowSpan\" != l3 && \"colSpan\" != l3 && \"role\" != l3 && \"popover\" != l3 && l3 in n2) try {\n n2[l3] = null == u4 ? \"\" : u4;\n break n;\n } catch (n3) {\n }\n \"function\" == typeof u4 || (null == u4 || false === u4 && \"-\" != l3[4] ? n2.removeAttribute(l3) : n2.setAttribute(l3, \"popover\" == l3 && 1 == u4 ? \"\" : u4));\n }\n }\n function V(n2) {\n return function(u4) {\n if (this.l) {\n var t3 = this.l[u4.type + n2];\n if (null == u4[c]) u4[c] = h++;\n else if (u4[c] < t3[a]) return;\n return t3(l.event ? l.event(u4) : u4);\n }\n };\n }\n function q(n2, u4, t3, i3, r3, o3, e3, f4, c3, a3) {\n var s3, h3, p3, v3, y3, d3, _3, k3, x2, M, I2, P2, A2, H2, T2, j3, F = u4.type;\n if (void 0 !== u4.constructor) return null;\n 128 & t3.__u && (c3 = !!(32 & t3.__u), o3 = [f4 = u4.__e = t3.__e]), (s3 = l.__b) && s3(u4);\n n: if (\"function\" == typeof F) {\n h3 = e3.length;\n try {\n if (x2 = u4.props, M = F.prototype && F.prototype.render, I2 = (s3 = F.contextType) && i3[s3.__c], P2 = s3 ? I2 ? I2.props.value : s3.__ : i3, t3.__c ? k3 = (p3 = u4.__c = t3.__c).__ = p3.__E : (M ? u4.__c = p3 = new F(x2, P2) : (u4.__c = p3 = new C(x2, P2), p3.constructor = F, p3.render = Q), I2 && I2.sub(p3), p3.state || (p3.state = {}), p3.__n = i3, v3 = p3.__d = true, p3.__h = [], p3._sb = []), M && null == p3.__s && (p3.__s = p3.state), M && null != F.getDerivedStateFromProps && (p3.__s == p3.state && (p3.__s = m({}, p3.__s)), m(p3.__s, F.getDerivedStateFromProps(x2, p3.__s))), y3 = p3.props, d3 = p3.state, p3.__v = u4, v3) M && null == F.getDerivedStateFromProps && null != p3.componentWillMount && p3.componentWillMount(), M && null != p3.componentDidMount && p3.__h.push(p3.componentDidMount);\n else {\n if (M && null == F.getDerivedStateFromProps && x2 !== y3 && null != p3.componentWillReceiveProps && p3.componentWillReceiveProps(x2, P2), u4.__v == t3.__v || !p3.__e && null != p3.shouldComponentUpdate && false === p3.shouldComponentUpdate(x2, p3.__s, P2)) {\n u4.__v != t3.__v && (p3.props = x2, p3.state = p3.__s, p3.__d = false), u4.__e = t3.__e, u4.__k = t3.__k, u4.__k.some(function(n3) {\n n3 && (n3.__ = u4);\n }), w.push.apply(p3.__h, p3._sb), p3._sb = [], p3.__h.length && e3.push(p3), f4 = $(t3);\n break n;\n }\n null != p3.componentWillUpdate && p3.componentWillUpdate(x2, p3.__s, P2), M && null != p3.componentDidUpdate && p3.__h.push(function() {\n p3.componentDidUpdate(y3, d3, _3);\n });\n }\n if (p3.context = P2, p3.props = x2, p3.__P = n2, p3.__e = false, A2 = l.__r, H2 = 0, M) p3.state = p3.__s, p3.__d = false, A2 && A2(u4), s3 = p3.render(p3.props, p3.state, p3.context), w.push.apply(p3.__h, p3._sb), p3._sb = [];\n else do {\n p3.__d = false, A2 && A2(u4), s3 = p3.render(p3.props, p3.state, p3.context), p3.state = p3.__s;\n } while (p3.__d && ++H2 < 25);\n p3.state = p3.__s, null != p3.getChildContext && (i3 = m(m({}, i3), p3.getChildContext())), M && !v3 && null != p3.getSnapshotBeforeUpdate && (_3 = p3.getSnapshotBeforeUpdate(y3, d3)), T2 = null != s3 && s3.type === S && null == s3.key ? E(s3.props.children) : s3, f4 = L(n2, g(T2) ? T2 : [T2], u4, t3, i3, r3, o3, e3, f4, c3, a3), p3.base = u4.__e, u4.__u &= -161, p3.__h.length && e3.push(p3), k3 && (p3.__E = p3.__ = null);\n } catch (n3) {\n if (e3.length = h3, u4.__v = null, c3 || null != o3) {\n if (n3.then) {\n for (u4.__u |= c3 ? 160 : 128; f4 && 8 == f4.nodeType && f4.nextSibling; ) f4 = f4.nextSibling;\n null != o3 && (o3[o3.indexOf(f4)] = null), u4.__e = f4;\n } else if (null != o3) for (j3 = o3.length; j3--; ) b(o3[j3]);\n } else u4.__e = t3.__e;\n null == u4.__k && (u4.__k = t3.__k || []), n3.then || B(u4), l.__e(n3, u4, t3);\n }\n } else null == o3 && u4.__v == t3.__v ? (u4.__k = t3.__k, u4.__e = t3.__e) : f4 = u4.__e = G(t3.__e, u4, t3, i3, r3, o3, e3, c3, a3);\n return (s3 = l.diffed) && s3(u4), 128 & u4.__u ? void 0 : f4;\n }\n function B(n2) {\n n2 && (n2.__c && (n2.__c.__e = true), n2.__k && n2.__k.some(B));\n }\n function D(n2, u4, t3) {\n for (var i3 = 0; i3 < t3.length; i3++) J(t3[i3], t3[++i3], t3[++i3]);\n l.__c && l.__c(u4, n2), n2.some(function(u5) {\n try {\n n2 = u5.__h, u5.__h = [], n2.some(function(n3) {\n n3.call(u5);\n });\n } catch (n3) {\n l.__e(n3, u5.__v);\n }\n });\n }\n function E(n2) {\n return \"object\" != typeof n2 || null == n2 || n2.__b > 0 ? n2 : g(n2) ? n2.map(E) : void 0 !== n2.constructor ? null : m({}, n2);\n }\n function G(u4, t3, i3, r3, o3, e3, f4, c3, a3) {\n var s3, h3, p3, v3, y3, w3, _3, m3 = i3.props || d, k3 = t3.props, x2 = t3.type;\n if (\"svg\" == x2 ? o3 = \"http://www.w3.org/2000/svg\" : \"math\" == x2 ? o3 = \"http://www.w3.org/1998/Math/MathML\" : o3 || (o3 = \"http://www.w3.org/1999/xhtml\"), null != e3) {\n for (s3 = 0; s3 < e3.length; s3++) if ((y3 = e3[s3]) && \"setAttribute\" in y3 == !!x2 && (x2 ? y3.localName == x2 : 3 == y3.nodeType)) {\n u4 = y3, e3[s3] = null;\n break;\n }\n }\n if (null == u4) {\n if (null == x2) return document.createTextNode(k3);\n u4 = document.createElementNS(o3, x2, k3.is && k3), c3 && (l.__m && l.__m(t3, e3), c3 = false), e3 = null;\n }\n if (null == x2) m3 === k3 || c3 && u4.data == k3 || (u4.data = k3);\n else {\n if (e3 = \"textarea\" == x2 && null != k3.defaultValue ? null : e3 && n.call(u4.childNodes), !c3 && null != e3) for (m3 = {}, s3 = 0; s3 < u4.attributes.length; s3++) m3[(y3 = u4.attributes[s3]).name] = y3.value;\n for (s3 in m3) y3 = m3[s3], \"dangerouslySetInnerHTML\" == s3 ? p3 = y3 : \"children\" == s3 || s3 in k3 || \"value\" == s3 && \"defaultValue\" in k3 || \"checked\" == s3 && \"defaultChecked\" in k3 || N(u4, s3, null, y3, o3);\n for (s3 in k3) y3 = k3[s3], \"children\" == s3 ? v3 = y3 : \"dangerouslySetInnerHTML\" == s3 ? h3 = y3 : \"value\" == s3 ? w3 = y3 : \"checked\" == s3 ? _3 = y3 : c3 && \"function\" != typeof y3 || m3[s3] === y3 || N(u4, s3, y3, m3[s3], o3);\n if (h3) c3 || p3 && (h3.__html == p3.__html || h3.__html == u4.innerHTML) || (u4.innerHTML = h3.__html), t3.__k = [];\n else if (p3 && (u4.innerHTML = \"\"), L(\"template\" == t3.type ? u4.content : u4, g(v3) ? v3 : [v3], t3, i3, r3, \"foreignObject\" == x2 ? \"http://www.w3.org/1999/xhtml\" : o3, e3, f4, e3 ? e3[0] : i3.__k && $(i3, 0), c3, a3), null != e3) for (s3 = e3.length; s3--; ) b(e3[s3]);\n c3 && \"textarea\" != x2 || (s3 = \"value\", \"progress\" == x2 && null == w3 ? u4.removeAttribute(\"value\") : null != w3 && (w3 !== u4[s3] || \"progress\" == x2 && !w3 || \"option\" == x2 && w3 != m3[s3]) && N(u4, s3, w3, m3[s3], o3), s3 = \"checked\", null != _3 && _3 != u4[s3] && N(u4, s3, _3, m3[s3], o3));\n }\n return u4;\n }\n function J(n2, u4, t3) {\n try {\n if (\"function\" == typeof n2) {\n var i3 = \"function\" == typeof n2.__u;\n i3 && n2.__u(), i3 && null == u4 || (n2.__u = n2(u4));\n } else n2.current = u4;\n } catch (n3) {\n l.__e(n3, t3);\n }\n }\n function K(n2, u4, t3) {\n var i3, r3;\n if (l.unmount && l.unmount(n2), (i3 = n2.ref) && (i3.current && i3.current != n2.__e || J(i3, null, u4)), null != (i3 = n2.__c)) {\n if (i3.componentWillUnmount) try {\n i3.componentWillUnmount();\n } catch (n3) {\n l.__e(n3, u4);\n }\n i3.base = i3.__P = i3.__n = null;\n }\n if (i3 = n2.__k) for (r3 = 0; r3 < i3.length; r3++) i3[r3] && K(i3[r3], u4, t3 || \"function\" != typeof n2.type);\n t3 || b(n2.__e), n2.__c = n2.__ = n2.__e = void 0;\n }\n function Q(n2, l3, u4) {\n return this.constructor(n2, u4);\n }\n function R(u4, t3, i3) {\n var r3, o3, e3, f4;\n t3 == document && (t3 = document.documentElement), l.__ && l.__(u4, t3), o3 = (r3 = \"function\" == typeof i3) ? null : i3 && i3.__k || t3.__k, e3 = [], f4 = [], q(t3, u4 = (!r3 && i3 || t3).__k = k(S, null, [u4]), o3 || d, d, t3.namespaceURI, !r3 && i3 ? [i3] : o3 ? null : t3.firstChild ? n.call(t3.childNodes) : null, e3, !r3 && i3 ? i3 : o3 ? o3.__e : t3.firstChild, r3, f4), D(e3, u4, f4), u4.props.children = null;\n }\n n = w.slice, l = { __e: function(n2, l3, u4, t3) {\n for (var i3, r3, o3; l3 = l3.__; ) if ((i3 = l3.__c) && !i3.__) try {\n if ((r3 = i3.constructor) && null != r3.getDerivedStateFromError && (i3.setState(r3.getDerivedStateFromError(n2)), o3 = i3.__d), null != i3.componentDidCatch && (i3.componentDidCatch(n2, t3 || {}), o3 = i3.__d), o3) return i3.__E = i3;\n } catch (l4) {\n n2 = l4;\n }\n throw n2;\n } }, u = 0, t = function(n2) {\n return null != n2 && void 0 === n2.constructor;\n }, C.prototype.setState = function(n2, l3) {\n var u4;\n u4 = null != this.__s && this.__s != this.state ? this.__s : this.__s = m({}, this.state), \"function\" == typeof n2 && (n2 = n2(m({}, u4), this.props)), n2 && m(u4, n2), null != n2 && this.__v && (l3 && this._sb.push(l3), A(this));\n }, C.prototype.forceUpdate = function(n2) {\n this.__v && (this.__e = true, n2 && this.__h.push(n2), A(this));\n }, C.prototype.render = S, i = [], o = \"function\" == typeof Promise ? Promise.prototype.then.bind(Promise.resolve()) : setTimeout, e = function(n2, l3) {\n return n2.__v.__b - l3.__v.__b;\n }, H.__r = 0, f = Math.random().toString(8), c = \"__d\" + f, a = \"__a\" + f, s = /(PointerCapture)$|Capture$/i, h = 0, p = V(false), v = V(true), y = 0;\n\n // node_modules/preact/hooks/dist/hooks.module.js\n var t2;\n var r2;\n var u2;\n var i2;\n var o2 = 0;\n var f2 = [];\n var c2 = l;\n var e2 = c2.__b;\n var a2 = c2.__r;\n var v2 = c2.diffed;\n var l2 = c2.__c;\n var m2 = c2.unmount;\n var p2 = c2.__;\n function s2(n2, t3) {\n c2.__h && c2.__h(r2, n2, o2 || t3), o2 = 0;\n var u4 = r2.__H || (r2.__H = { __: [], __h: [] });\n return n2 >= u4.__.length && u4.__.push({}), u4.__[n2];\n }\n function d2(n2) {\n return o2 = 1, y2(D2, n2);\n }\n function y2(n2, u4, i3) {\n var o3 = s2(t2++, 2);\n if (o3.t = n2, !o3.__c && (o3.__ = [i3 ? i3(u4) : D2(void 0, u4), function(n3) {\n var t3 = o3.__N ? o3.__N[0] : o3.__[0], r3 = o3.t(t3, n3);\n t3 !== r3 && (o3.__N = [r3, o3.__[1]], o3.__c.setState({}));\n }], o3.__c = r2, !r2.__f)) {\n var f4 = function(n3, t3, r3) {\n if (!o3.__c.__H) return true;\n var u5 = false, i4 = o3.__c.props !== n3;\n if (o3.__c.__H.__.some(function(n4) {\n if (n4.__N) {\n u5 = true;\n var t4 = n4.__[0];\n n4.__ = n4.__N, n4.__N = void 0, t4 !== n4.__[0] && (i4 = true);\n }\n }), c3) {\n var f5 = c3.call(this, n3, t3, r3);\n return u5 ? f5 || i4 : f5;\n }\n return !u5 || i4;\n };\n r2.__f = true;\n var c3 = r2.shouldComponentUpdate, e3 = r2.componentWillUpdate;\n r2.componentWillUpdate = function(n3, t3, r3) {\n if (this.__e) {\n var u5 = c3;\n c3 = void 0, f4(n3, t3, r3), c3 = u5;\n }\n e3 && e3.call(this, n3, t3, r3);\n }, r2.shouldComponentUpdate = f4;\n }\n return o3.__N || o3.__;\n }\n function h2(n2, u4) {\n var i3 = s2(t2++, 3);\n !c2.__s && C2(i3.__H, u4) && (i3.__ = n2, i3.u = u4, r2.__H.__h.push(i3));\n }\n function _2(n2, u4) {\n var i3 = s2(t2++, 4);\n !c2.__s && C2(i3.__H, u4) && (i3.__ = n2, i3.u = u4, r2.__h.push(i3));\n }\n function j2() {\n for (var n2; n2 = f2.shift(); ) {\n var t3 = n2.__H;\n if (n2.__P && t3) try {\n t3.__h.some(z2), t3.__h.some(B2), t3.__h = [];\n } catch (r3) {\n t3.__h = [], c2.__e(r3, n2.__v);\n }\n }\n }\n c2.__b = function(n2) {\n r2 = null, e2 && e2(n2);\n }, c2.__ = function(n2, t3) {\n n2 && t3.__k && t3.__k.__m && (n2.__m = t3.__k.__m), p2 && p2(n2, t3);\n }, c2.__r = function(n2) {\n a2 && a2(n2), t2 = 0;\n var i3 = (r2 = n2.__c).__H;\n i3 && (u2 === r2 ? (i3.__h = [], r2.__h = [], i3.__.some(function(n3) {\n n3.__N && (n3.__ = n3.__N), n3.u = n3.__N = void 0;\n })) : (i3.__h.some(z2), i3.__h.some(B2), i3.__h = [], t2 = 0)), u2 = r2;\n }, c2.diffed = function(n2) {\n v2 && v2(n2);\n var t3 = n2.__c;\n t3 && t3.__H && (t3.__H.__h.length && (1 !== f2.push(t3) && i2 === c2.requestAnimationFrame || ((i2 = c2.requestAnimationFrame) || w2)(j2)), t3.__H.__.some(function(n3) {\n n3.u && (n3.__H = n3.u, n3.u = void 0);\n })), u2 = r2 = null;\n }, c2.__c = function(n2, t3) {\n t3.some(function(n3) {\n try {\n n3.__h.some(z2), n3.__h = n3.__h.filter(function(n4) {\n return !n4.__ || B2(n4);\n });\n } catch (r3) {\n t3.some(function(n4) {\n n4.__h && (n4.__h = []);\n }), t3 = [], c2.__e(r3, n3.__v);\n }\n }), l2 && l2(n2, t3);\n }, c2.unmount = function(n2) {\n m2 && m2(n2);\n var t3, r3 = n2.__c;\n r3 && r3.__H && (r3.__H.__.some(function(n3) {\n try {\n z2(n3);\n } catch (n4) {\n t3 = n4;\n }\n }), r3.__H = void 0, t3 && c2.__e(t3, r3.__v));\n };\n var k2 = \"function\" == typeof requestAnimationFrame;\n function w2(n2) {\n var t3, r3 = function() {\n clearTimeout(u4), k2 && cancelAnimationFrame(t3), setTimeout(n2);\n }, u4 = setTimeout(r3, 35);\n k2 && (t3 = requestAnimationFrame(r3));\n }\n function z2(n2) {\n var t3 = r2, u4 = n2.__c;\n \"function\" == typeof u4 && (n2.__c = void 0, u4()), r2 = t3;\n }\n function B2(n2) {\n var t3 = r2;\n n2.__c = n2.__(), r2 = t3;\n }\n function C2(n2, t3) {\n return !n2 || n2.length !== t3.length || t3.some(function(t4, r3) {\n return t4 !== n2[r3];\n });\n }\n function D2(n2, t3) {\n return \"function\" == typeof t3 ? t3(n2) : t3;\n }\n\n // src/operator-ui/view.ts\n var OPERATOR_PAGES = [\n \"connections\",\n \"credentials\",\n \"tokens\",\n \"activity\"\n ];\n var PAGE_META = {\n connections: { path: \"/\", label: \"Connections\" },\n credentials: { path: \"/credentials\", label: \"Credentials\" },\n tokens: { path: \"/tokens\", label: \"Access tokens\" },\n activity: { path: \"/activity\", label: \"Activity\" }\n };\n function pageForPath(path) {\n const match = OPERATOR_PAGES.find((page) => PAGE_META[page].path === path);\n return match ?? \"connections\";\n }\n function info(message2) {\n return { message: message2, tone: \"info\" };\n }\n function failure(message2) {\n return { message: message2, tone: \"error\" };\n }\n function initialState(page) {\n return {\n page,\n generation: 0,\n session: \"loading\",\n gate: null,\n refreshing: false,\n pendingFocus: null,\n ...identityScopedState()\n };\n }\n function identityScopedState() {\n return {\n data: null,\n connectorFilter: \"\",\n oauthNotice: null,\n oauthBusy: null,\n credentialNotice: null,\n credentialEditing: null,\n credentialBusy: null,\n tokenPhase: \"idle\",\n tokenNotice: null,\n tokens: [],\n createdToken: null,\n tokenRenaming: null,\n tokenBusy: false,\n activityPhase: \"idle\",\n activityNotice: null,\n activityEvents: [],\n activityCursor: null,\n activitySearch: \"\"\n };\n }\n function resetIdentity(state2, gate2 = null) {\n return {\n ...state2,\n generation: state2.generation + 1,\n session: \"gated\",\n gate: gate2,\n refreshing: false,\n pendingFocus: null,\n ...identityScopedState()\n };\n }\n function withPage(state2, page) {\n return {\n ...state2,\n page,\n createdToken: null,\n tokenRenaming: null,\n tokenNotice: null,\n credentialEditing: null,\n credentialNotice: null\n };\n }\n function credentialUnavailableCopy(capability) {\n if (capability === \"no_slots\") {\n return \"No connectors declare operator-managed credential slots. Connector credentials remain configuration-as-code until a slot is declared.\";\n }\n if (capability === \"vault_not_configured\") {\n return \"Credential storage is not configured. Set credentials.encryptionKey before managing connector credentials here.\";\n }\n return \"Credential management requires an interactive user. Bearer-authenticated sessions can inspect connections but cannot manage stored credentials.\";\n }\n function accessTokenUnavailableCopy(capability) {\n if (capability === \"not_configured\") {\n return \"Access tokens are not configured for this deployment. Add accessTokens to the deployment configuration to enable them.\";\n }\n return \"Access token management requires an eligible interactive operator. A Bearer token can connect to MCP, but it cannot create or revoke other tokens.\";\n }\n function connectorStatusLabel(status) {\n if (status === \"ok\") return \"Connected\";\n if (status === \"auth_required\") return \"Authorization needed\";\n return \"Unavailable\";\n }\n function toolCountLabel(count) {\n return `${count} ${count === 1 ? \"tool\" : \"tools\"}`;\n }\n var DRIFT_CATEGORIES = [\n { key: \"unclassifiedTools\", label: \"Unclassified\" },\n { key: \"unservedTools\", label: \"Unserved\" },\n { key: \"annotationConflicts\", label: \"Annotation conflicts\" },\n { key: \"schemaChanges\", label: \"Schema changes\" }\n ];\n function driftTotal(drift) {\n if (!drift) return 0;\n return DRIFT_CATEGORIES.reduce((sum, { key }) => sum + (drift[key] || 0), 0);\n }\n function driftState(drift) {\n if (!drift) return \"unavailable\";\n return driftTotal(drift) > 0 ? \"warning\" : \"clean\";\n }\n function driftCounts(drift) {\n if (!drift) return [];\n return DRIFT_CATEGORIES.map(({ key, label }) => ({\n key,\n label,\n count: drift[key] || 0\n }));\n }\n function driftSummary(drift) {\n const state2 = driftState(drift);\n if (state2 === \"unavailable\") {\n return \"No catalog refresh observed yet in this runtime.\";\n }\n const observed = formatDate(drift?.observedAt);\n const when = observed ? ` · observed ${observed}` : \"\";\n if (state2 === \"clean\") return `Matches the reviewed manifest${when}`;\n const total = driftTotal(drift);\n return `${total} difference${total === 1 ? \"\" : \"s\"} from the reviewed manifest${when}`;\n }\n function safeHttpHref(url) {\n if (!url) return null;\n try {\n const protocol = new URL(url).protocol;\n return protocol === \"http:\" || protocol === \"https:\" ? url : null;\n } catch {\n return null;\n }\n }\n function formatDate(value) {\n if (!value) return \"\";\n const date = new Date(value);\n return Number.isNaN(date.valueOf()) ? \"\" : date.toLocaleString();\n }\n function actorLabel(actor) {\n if (!actor?.kind) return \"unknown\";\n if (actor.label) return `${actor.kind} · ${actor.label}`;\n return actor.id ? `${actor.kind} · ${actor.id}` : actor.kind;\n }\n function actorStableId(actor) {\n if (!actor?.id) return null;\n if (!actor.label && !actor.namespace) return null;\n return actor.namespace ? `${actor.namespace} · ${actor.id}` : actor.id;\n }\n function activityMatches(event, query) {\n const q2 = query.trim().toLowerCase();\n if (!q2) return true;\n return [\n event.address,\n event.connectorId,\n event.toolName,\n event.source,\n event.outcome,\n event.errorCode,\n event.friction,\n event.actor?.kind,\n event.actor?.id,\n event.actor?.namespace,\n event.actor?.label\n ].some((value) => String(value ?? \"\").toLowerCase().includes(q2));\n }\n function filterActivity(events, query) {\n return events.filter((event) => activityMatches(event, query));\n }\n function activitySummary(events) {\n if (events.length === 0) return \"Arguments and results are never stored.\";\n const tools = new Set(events.map((event) => event.address)).size;\n return `${events.length} loaded call${events.length === 1 ? \"\" : \"s\"} · ${tools} tool${tools === 1 ? \"\" : \"s\"} · no arguments or results stored`;\n }\n var ACTIVITY_OUTCOMES = [\"success\", \"error\", \"timeout\", \"cancelled\"];\n function activityOutcomeClass(outcome) {\n return ACTIVITY_OUTCOMES.includes(outcome) ? outcome : \"error\";\n }\n function activityDetail(event) {\n const parts = [event.source];\n if (event.attempts > 1) parts.push(`${event.attempts} attempts`);\n if (event.friction) parts.push(event.friction);\n if (event.errorCode && event.errorCode !== event.friction) {\n parts.push(event.errorCode);\n }\n return parts.join(\" · \");\n }\n function credentialStateLabel(credential) {\n if (!credential.configured) return \"not configured\";\n const masked = credential.fields?.length ? \"configured\" : `configured · ••••${credential.lastFour ?? \"\"}`;\n return credential.updatedAt ? `${masked} · updated ${formatDate(credential.updatedAt)}` : masked;\n }\n function gateCopy(kind, signedIn) {\n if (kind === \"cloudflare-access\") {\n return \"Cloudflare Access admitted this browser, but the current identity cannot open deployment-wide operator pages.\";\n }\n if (kind !== \"clerk\") {\n return \"Paste an operator bearer token to open this page. Nothing is requested until you do.\";\n }\n return signedIn ? \"Signed in with Clerk, but this account cannot open deployment-wide operator pages.\" : \"Sign in with Clerk to open this operator page.\";\n }\n\n // src/operator-ui/app/config.ts\n var auth = AUTH;\n var mcpUrl = MCP_URL;\n var initialPage = INITIAL_PAGE;\n var titleSuffix = TITLE_SUFFIX;\n var productName = PRODUCT_NAME;\n var productDescription = PRODUCT_DESCRIPTION;\n var productOperatorLabel = PRODUCT_OPERATOR_LABEL;\n var TOKEN_KEY = \"connecta:token\";\n\n // src/operator-ui/app/store.ts\n var state = initialState(initialPage);\n var listeners = /* @__PURE__ */ new Set();\n function getState() {\n return state;\n }\n function subscribe(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n }\n function set(patch) {\n state = { ...state, ...patch };\n for (const listener of listeners) listener();\n }\n function fence() {\n const generation = state.generation;\n return () => generation === state.generation;\n }\n function message(error, fallback) {\n return error instanceof Error && error.message ? error.message : fallback;\n }\n function sessionToken() {\n if (auth.kind === \"cloudflare-access\") return Promise.resolve(void 0);\n return auth.kind === \"clerk\" ? Promise.resolve(window.Clerk?.session?.getToken() ?? null) : Promise.resolve(localStorage.getItem(TOKEN_KEY));\n }\n function requestHeaders(token, body = false) {\n return {\n ...token ? { Authorization: `Bearer ${token}` } : {},\n ...body ? { \"Content-Type\": \"application/json\" } : {}\n };\n }\n function gate(notice = null) {\n state = resetIdentity(state, notice);\n for (const listener of listeners) listener();\n }\n async function operatorRequest(path, method, current, body) {\n const token = await sessionToken();\n if (!current()) throw new Error(\"The operator session changed.\");\n if (!token && auth.kind !== \"cloudflare-access\") {\n throw new Error(\"Your operator session has expired.\");\n }\n const res = await fetch(path, {\n method,\n headers: requestHeaders(token, Boolean(body)),\n credentials: \"same-origin\",\n ...body ? { body: JSON.stringify(body) } : {}\n });\n if (res.status === 204) return null;\n let payload = {};\n try {\n payload = await res.json();\n } catch {\n }\n if (res.status === 401) {\n throw new Error(\"Your operator session was not accepted. Sign in again.\");\n }\n if (res.status === 403) {\n throw new Error(\"This identity may not perform that action.\");\n }\n if (!res.ok) {\n throw new Error(payload.error || `Request failed (${res.status}).`);\n }\n return payload;\n }\n async function loadData() {\n const current = fence();\n if (state.session === \"ready\") set({ refreshing: true });\n let token;\n try {\n token = await sessionToken();\n } catch (error) {\n if (!current()) return;\n const why = message(error, \"unknown error\");\n return gate(failure(`Could not read the Clerk session: ${why}`));\n }\n if (!current()) return;\n if (!token && auth.kind !== \"cloudflare-access\") return gate(null);\n let res;\n try {\n res = await fetch(\"/ui/data\", {\n headers: requestHeaders(token),\n credentials: \"same-origin\"\n });\n } catch (error) {\n if (!current()) return;\n return gate(failure(`Network error: ${message(error, \"unknown error\")}`));\n }\n if (!current()) return;\n if (res.status === 401 || res.status === 403) {\n if (auth.kind === \"clerk\") {\n return gate(\n failure(\n res.status === 403 ? \"This Clerk account is not allowed to access connecta.\" : \"Your Clerk session was not accepted. Sign out and try again.\"\n )\n );\n }\n if (auth.kind === \"cloudflare-access\") {\n return gate(\n failure(\n \"Cloudflare Access admitted the request, but this identity is not an eligible operator.\"\n )\n );\n }\n localStorage.removeItem(TOKEN_KEY);\n return gate(failure(\"Token rejected — enter a valid bearer token.\"));\n }\n if (!res.ok) return gate(failure(`Error ${res.status}`));\n let data;\n try {\n data = await res.json();\n } catch {\n if (!current()) return;\n return gate(failure(\"Operator data could not be read.\"));\n }\n if (!current()) return;\n set({ data, session: \"ready\", gate: null, refreshing: false });\n }\n async function mutate(options) {\n const current = fence();\n set(options.busy);\n try {\n const payload = await options.request(current);\n if (!current()) return;\n if (options.reload) await loadData();\n if (!current()) return;\n set(options.done(payload));\n } catch (error) {\n if (!current()) return;\n if (options.reload) {\n try {\n await loadData();\n } catch {\n }\n if (!current()) return;\n }\n set(options.failed(failure(message(error, options.fallback))));\n }\n }\n function focusHandled() {\n if (state.pendingFocus !== null) set({ pendingFocus: null });\n }\n function setPage(page, focus = false) {\n state = withPage(state, page);\n if (focus) {\n state = {\n ...state,\n pendingFocus: state.session === \"ready\" ? `${page}Heading` : \"gateHeading\"\n };\n }\n for (const listener of listeners) listener();\n }\n function navigate(page, href) {\n history.pushState({ operatorPage: page }, \"\", href);\n setPage(page, true);\n }\n function setConnectorFilter(connectorFilter) {\n set({ connectorFilter });\n }\n function setActivitySearch(activitySearch) {\n set({ activitySearch });\n }\n function signInWithBearer(value) {\n gate(null);\n localStorage.setItem(TOKEN_KEY, value);\n void loadData().then(() => {\n if (state.session === \"ready\") set({ pendingFocus: `${state.page}Heading` });\n });\n }\n function forgetBearer() {\n localStorage.removeItem(TOKEN_KEY);\n gate(null);\n set({ pendingFocus: \"token\" });\n }\n function oauthAction(connector, action) {\n const disconnecting = action === \"disconnect\";\n const confirmed = window.confirm(\n disconnecting ? `Disconnect OAuth for ${connector}? Stored credentials and any pending authorization will be removed.` : `Restart OAuth for ${connector}? Stored credentials and any pending authorization will be replaced.`\n );\n if (!confirmed) return Promise.resolve();\n return mutate({\n request: (current) => operatorRequest(\n `/ui/oauth/${encodeURIComponent(connector)}`,\n disconnecting ? \"DELETE\" : \"POST\",\n current\n ),\n busy: { oauthNotice: null, oauthBusy: connector },\n done: (payload) => ({\n oauthBusy: null,\n pendingFocus: \"oauthNotice\",\n oauthNotice: info(\n disconnecting ? \"OAuth disconnected. Restart authorization when you are ready to reconnect.\" : payload?.message || \"Authorization restarted. Open the authorization link to reconnect.\"\n )\n }),\n failed: (notice) => ({\n oauthBusy: null,\n oauthNotice: notice,\n pendingFocus: \"oauthNotice\"\n }),\n fallback: \"OAuth action failed.\",\n reload: true\n });\n }\n function editCredential(connector) {\n set({ credentialEditing: connector, credentialNotice: null });\n }\n function refuseCredential(copy) {\n set({ credentialNotice: failure(copy), pendingFocus: \"credentialNotice\" });\n }\n function credentialMutation(connector, request, done, reload = true) {\n const land = (credentialNotice) => ({\n credentialBusy: null,\n credentialNotice,\n pendingFocus: \"credentialNotice\"\n });\n return mutate({\n request,\n busy: { credentialBusy: connector, credentialNotice: null },\n done: (payload) => land(done(payload)),\n failed: land,\n fallback: \"Credential action failed.\",\n reload\n });\n }\n function saveCredential(connector, body) {\n return credentialMutation(\n connector,\n (current) => operatorRequest(\n `/ui/credentials/${encodeURIComponent(connector)}`,\n \"PUT\",\n current,\n body\n ),\n () => {\n set({ credentialEditing: null });\n return info(\"Credential saved.\");\n }\n );\n }\n function removeCredential(connector) {\n const confirmed = window.confirm(\n \"Remove this credential? The connector will stop authenticating until a replacement is added.\"\n );\n if (!confirmed) return Promise.resolve();\n return credentialMutation(\n connector,\n (current) => operatorRequest(\n `/ui/credentials/${encodeURIComponent(connector)}`,\n \"DELETE\",\n current\n ),\n () => info(\"Credential removed.\")\n );\n }\n function testCredential(connector) {\n return credentialMutation(\n connector,\n (current) => operatorRequest(\n `/ui/credentials/${encodeURIComponent(connector)}/test`,\n \"POST\",\n current\n ),\n (payload) => {\n const copy = payload?.message || (payload?.ok ? \"Credential is valid.\" : \"Credential test failed.\");\n return payload?.ok ? info(copy) : failure(copy);\n },\n false\n );\n }\n async function loadAccessTokens() {\n const current = fence();\n set({ tokenPhase: \"loading\", tokenNotice: null });\n try {\n const payload = await operatorRequest(\"/ui/access-tokens\", \"GET\", current);\n if (!current()) return;\n set({ tokenPhase: \"ready\", tokens: payload?.accessTokens ?? [] });\n } catch (error) {\n if (!current()) return;\n set({\n tokenPhase: \"error\",\n tokenNotice: failure(\n message(error, \"Access tokens could not be loaded.\")\n )\n });\n }\n }\n function tokenFailure(tokenNotice) {\n return { tokenBusy: false, tokenNotice, pendingFocus: \"tokenNotice\" };\n }\n function createAccessToken(name) {\n if (!name) {\n set(tokenFailure(failure(\"Name the MCP client before creating a token.\")));\n return Promise.resolve(false);\n }\n let created = false;\n return mutate({\n request: (current) => operatorRequest(\"/ui/access-tokens\", \"POST\", current, { name }),\n busy: { tokenBusy: true, tokenNotice: null },\n done: (payload) => {\n const issued = payload?.accessToken;\n if (!payload?.token || !issued) {\n throw new Error(\"The created token was not returned.\");\n }\n created = true;\n return {\n tokenBusy: false,\n tokenPhase: \"ready\",\n tokens: [\n issued,\n ...state.tokens.filter((token) => token.id !== issued.id)\n ],\n createdToken: payload.token,\n tokenNotice: info(\"Access token created.\"),\n pendingFocus: \"tokenRevealHeading\"\n };\n },\n failed: tokenFailure,\n fallback: \"Access token could not be created.\"\n }).then(() => created);\n }\n function dismissCreatedToken() {\n set({ createdToken: null });\n }\n function renameAccessToken(id) {\n set({ tokenRenaming: id });\n }\n function accessTokenMutation(id, method, body, success, fallback) {\n return mutate({\n request: (current) => operatorRequest(\n `/ui/access-tokens/${encodeURIComponent(id)}`,\n method,\n current,\n body\n ),\n busy: { tokenBusy: true, tokenNotice: null },\n done: (payload) => ({\n tokenBusy: false,\n tokenRenaming: null,\n tokenNotice: info(success),\n pendingFocus: \"tokenNotice\",\n ...payload?.accessToken ? {\n tokens: state.tokens.map(\n (token) => token.id === id ? payload.accessToken : token\n )\n } : {}\n }),\n failed: tokenFailure,\n fallback\n });\n }\n function saveAccessTokenName(id, name) {\n return accessTokenMutation(\n id,\n \"PUT\",\n { name },\n \"Access token renamed.\",\n \"Access token could not be renamed.\"\n );\n }\n function revokeAccessToken(id) {\n const named = state.tokens.find((token) => token.id === id);\n const confirmed = window.confirm(\n `Revoke ${named?.name || \"this access token\"}? Its MCP client will immediately lose access.`\n );\n if (!confirmed) return Promise.resolve();\n return accessTokenMutation(\n id,\n \"DELETE\",\n void 0,\n \"Access token revoked.\",\n \"Access token could not be revoked.\"\n );\n }\n async function loadActivity(reset) {\n if (!state.data?.activityEnabled) return;\n const current = fence();\n set({\n activityPhase: \"loading\",\n activityNotice: null,\n ...reset ? { activityEvents: [], activityCursor: null } : {}\n });\n const params = new URLSearchParams({ limit: \"50\" });\n if (!reset && state.activityCursor) {\n params.set(\"cursor\", state.activityCursor);\n }\n try {\n const payload = await operatorRequest(\n `/ui/activity?${params}`,\n \"GET\",\n current\n );\n if (!current()) return;\n set({\n activityPhase: \"ready\",\n activityEvents: [\n ...reset ? [] : state.activityEvents,\n ...payload?.events ?? []\n ],\n activityCursor: payload?.nextCursor ?? null\n });\n } catch (error) {\n if (!current()) return;\n set({\n activityPhase: \"error\",\n activityNotice: failure(message(error, \"Activity could not be loaded.\"))\n });\n }\n }\n function signIn() {\n window.Clerk?.redirectToSignIn({\n signInFallbackRedirectUrl: window.location.href,\n signUpFallbackRedirectUrl: window.location.href\n });\n }\n function signOut() {\n if (auth.kind === \"cloudflare-access\") {\n gate(null);\n window.location.assign(\"/cdn-cgi/access/logout\");\n return;\n }\n const clerk = window.Clerk;\n gate(null);\n void clerk?.signOut({ redirectUrl: window.location.href });\n }\n async function boot() {\n const onPop = () => setPage(pageForPath(window.location.pathname), true);\n window.addEventListener(\"popstate\", onPop);\n window.addEventListener(\"pagehide\", dismissCreatedToken);\n if (auth.kind === \"clerk\") {\n const clerk = window.Clerk;\n if (!clerk) {\n const why = \"Clerk could not load. Check your network and try again.\";\n return gate(failure(why));\n }\n try {\n await clerk.load({\n ...auth.signInUrl ? { signInUrl: auth.signInUrl } : {},\n ...auth.signUpUrl ? { signUpUrl: auth.signUpUrl } : {},\n signInFallbackRedirectUrl: window.location.href,\n signUpFallbackRedirectUrl: window.location.href,\n afterSignOutUrl: window.location.href\n });\n let sessionId = clerk.session?.id ?? null;\n clerk.addListener((resources) => {\n const next = resources.session?.id ?? null;\n if (next === sessionId) return;\n sessionId = next;\n gate(null);\n void loadData();\n });\n } catch (error) {\n const why = message(error, \"unknown error\");\n return gate(failure(`Clerk could not initialize: ${why}`));\n }\n }\n await loadData();\n }\n\n // node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js\n var f3 = 0;\n function u3(e3, t3, n2, o3, i3, u4) {\n t3 || (t3 = {});\n var a3, c3, p3 = t3;\n if (\"ref\" in p3) for (c3 in p3 = {}, t3) \"ref\" == c3 ? a3 = t3[c3] : p3[c3] = t3[c3];\n var l3 = { type: e3, props: p3, key: n2, ref: a3, __k: null, __: null, __b: 0, __e: null, __c: null, constructor: void 0, __v: --f3, __i: -1, __u: 0, __source: i3, __self: u4 };\n if (\"function\" == typeof e3 && (a3 = e3.defaultProps)) for (c3 in a3) void 0 === p3[c3] && (p3[c3] = a3[c3]);\n return l.vnode && l.vnode(l3), l3;\n }\n\n // src/operator-ui/app/parts.tsx\n function NoticeLine({\n id,\n notice,\n className = \"meta\"\n }) {\n return /* @__PURE__ */ u3(\n \"p\",\n {\n id,\n class: notice?.tone === \"error\" ? `${className} error-notice` : className,\n role: notice?.tone === \"error\" ? \"alert\" : \"status\",\n \"aria-live\": \"polite\",\n tabIndex: -1,\n children: notice ? notice.message : null\n }\n );\n }\n function Empty({ children }) {\n return /* @__PURE__ */ u3(\"p\", { class: \"empty\", children });\n }\n function Unavailable({ children }) {\n return /* @__PURE__ */ u3(\"div\", { class: \"unavailable\", children });\n }\n function PageLink({\n page,\n class: className,\n current,\n children\n }) {\n const href = PAGE_META[page].path;\n return /* @__PURE__ */ u3(\n \"a\",\n {\n class: className,\n href,\n ...current ? { \"aria-current\": \"page\" } : {},\n onClick: (event) => {\n if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {\n return;\n }\n event.preventDefault();\n navigate(page, href);\n },\n children\n }\n );\n }\n function CopyButton({\n value,\n label,\n class: className = \"linklike\"\n }) {\n const [status, setStatus] = d2(\"idle\");\n h2(() => {\n if (status === \"idle\") return;\n const timer = window.setTimeout(() => setStatus(\"idle\"), 1600);\n return () => window.clearTimeout(timer);\n }, [status]);\n return /* @__PURE__ */ u3(\n \"button\",\n {\n class: className,\n type: \"button\",\n onClick: () => {\n navigator.clipboard.writeText(value).then(\n () => setStatus(\"copied\"),\n () => setStatus(\"failed\")\n );\n },\n children: status === \"copied\" ? \"Copied\" : status === \"failed\" ? \"Copy failed\" : label\n }\n );\n }\n\n // src/operator-ui/app/activity.tsx\n function ActivityRow({ event }) {\n const outcome = activityOutcomeClass(event.outcome);\n const stableId = actorStableId(event.actor);\n return /* @__PURE__ */ u3(\"article\", { class: `activity-item ${outcome}`, children: [\n /* @__PURE__ */ u3(\"div\", { class: \"activity-stamp\", children: [\n /* @__PURE__ */ u3(\n \"span\",\n {\n class: outcome === \"success\" ? \"dot ok\" : \"dot\",\n \"aria-hidden\": \"true\"\n }\n ),\n /* @__PURE__ */ u3(\"div\", { children: [\n /* @__PURE__ */ u3(\"time\", { class: \"activity-time\", dateTime: event.occurredAt, children: formatDate(event.occurredAt) }),\n /* @__PURE__ */ u3(\"div\", { class: \"activity-actor\", children: actorLabel(event.actor) }),\n stableId ? /* @__PURE__ */ u3(\"div\", { class: \"activity-actor-id mono\", children: stableId }) : null\n ] })\n ] }),\n /* @__PURE__ */ u3(\"div\", { children: [\n /* @__PURE__ */ u3(\"div\", { class: \"activity-address\", children: event.address }),\n /* @__PURE__ */ u3(\"div\", { class: \"activity-detail\", children: activityDetail(event) })\n ] }),\n /* @__PURE__ */ u3(\"div\", { children: [\n /* @__PURE__ */ u3(\"div\", { class: \"activity-outcome\", children: event.outcome }),\n /* @__PURE__ */ u3(\"div\", { class: \"activity-detail\", children: [\n event.durationMs,\n \" ms\"\n ] })\n ] })\n ] });\n }\n function ActivityPage({ state: state2 }) {\n const enabled = Boolean(state2.data?.activityEnabled);\n const loading = state2.activityPhase === \"loading\";\n const visible = filterActivity(state2.activityEvents, state2.activitySearch);\n return /* @__PURE__ */ u3(\"section\", { id: \"activityView\", children: /* @__PURE__ */ u3(\"div\", { class: \"lead pgrid\", children: [\n /* @__PURE__ */ u3(\"h1\", { id: \"activityHeading\", class: \"pcap\", tabIndex: -1, children: \"Activity\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"pbody\", children: [\n /* @__PURE__ */ u3(\"p\", { class: \"activity-copy\", id: \"activitySummary\", children: activitySummary(state2.activityEvents) }),\n !enabled ? /* @__PURE__ */ u3(Unavailable, { children: [\n \"Activity history is not configured. Add an\",\n \" \",\n /* @__PURE__ */ u3(\"span\", { class: \"mono\", children: \"activity.store\" }),\n \" with a list reader to enable this page.\"\n ] }) : /* @__PURE__ */ u3(\"div\", { id: \"activityAvailable\", children: [\n /* @__PURE__ */ u3(\"div\", { class: \"row activity-controls\", children: [\n /* @__PURE__ */ u3(\n \"input\",\n {\n id: \"activitySearch\",\n type: \"search\",\n placeholder: \"Search user, tool, or outcome…\",\n \"aria-label\": \"Search loaded activity\",\n value: state2.activitySearch,\n onInput: (event) => setActivitySearch(event.currentTarget.value)\n }\n ),\n /* @__PURE__ */ u3(\n \"button\",\n {\n id: \"refreshActivity\",\n class: \"linklike\",\n type: \"button\",\n disabled: loading,\n onClick: () => void loadActivity(true),\n children: loading ? \"Loading…\" : \"Refresh\"\n }\n )\n ] }),\n /* @__PURE__ */ u3(NoticeLine, { id: \"activityNotice\", notice: state2.activityNotice }),\n /* @__PURE__ */ u3(\n \"div\",\n {\n id: \"activityList\",\n class: \"activity-ledger\",\n \"aria-busy\": loading ? \"true\" : \"false\",\n children: loading && state2.activityEvents.length === 0 ? /* @__PURE__ */ u3(\"div\", { class: \"activity-empty\", children: \"Loading activity…\" }) : state2.activityPhase === \"error\" && state2.activityEvents.length === 0 ? /* @__PURE__ */ u3(\"p\", { class: \"activity-empty\", children: /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n onClick: () => void loadActivity(true),\n children: \"Try loading activity again\"\n }\n ) }) : visible.length === 0 ? /* @__PURE__ */ u3(\"div\", { class: \"activity-empty\", children: state2.activitySearch.trim() ? \"No loaded activity matches this search.\" : \"No connector tool calls recorded yet.\" }) : visible.map((event, index) => /* @__PURE__ */ u3(\n ActivityRow,\n {\n event\n },\n `${event.occurredAt}-${event.address}-${index}`\n ))\n }\n ),\n state2.activityCursor ? /* @__PURE__ */ u3(\n \"button\",\n {\n id: \"moreActivity\",\n class: \"linklike activity-more\",\n type: \"button\",\n disabled: loading,\n onClick: () => void loadActivity(false),\n children: loading ? \"Loading…\" : \"Load older\"\n }\n ) : null\n ] })\n ] })\n ] }) });\n }\n\n // src/operator-ui/model.ts\n function filterUiConnectors(connectors, query) {\n const q2 = query.trim().toLowerCase();\n const filtered = [];\n for (const connector of connectors) {\n const connectorText = [\n connector.id,\n connector.title,\n connector.description,\n connector.status\n ].join(\" \").toLowerCase();\n const connectorMatches = Boolean(q2 && connectorText.includes(q2));\n const tools = connector.tools.filter(\n (tool) => !q2 || connectorMatches || `${tool.name} ${tool.description ?? \"\"}`.toLowerCase().includes(q2)\n );\n if (q2 && tools.length === 0 && !connectorMatches) continue;\n filtered.push({ connector, tools });\n }\n return filtered;\n }\n\n // src/operator-ui/app/connections.tsx\n var DRIFT_HEADING = {\n clean: \"Catalog drift · none\",\n warning: \"Catalog drift · review\",\n unavailable: \"Catalog drift · not observed\"\n };\n function DriftPanel({ connector }) {\n const drift = connector.catalogDrift;\n const state2 = driftState(drift);\n return /* @__PURE__ */ u3(\n \"div\",\n {\n id: `drift-${connector.id}`,\n class: `connector-drift ${state2}`,\n \"data-drift\": state2,\n children: [\n /* @__PURE__ */ u3(\"p\", { class: \"cap\", children: DRIFT_HEADING[state2] }),\n /* @__PURE__ */ u3(\"p\", { class: \"meta drift-summary\", children: driftSummary(drift) }),\n state2 === \"unavailable\" ? null : /* @__PURE__ */ u3(\"ul\", { class: \"drift-counts\", children: driftCounts(drift).map(({ key, label, count }) => /* @__PURE__ */ u3(\"li\", { class: count > 0 ? \"drift-count flagged\" : \"drift-count\", children: [\n /* @__PURE__ */ u3(\"span\", { class: \"drift-count-value\", children: count }),\n /* @__PURE__ */ u3(\"span\", { class: \"drift-count-label\", children: label })\n ] }, key)) })\n ]\n }\n );\n }\n function ConnectorCard({\n connector,\n tools,\n expanded,\n oauthManagement,\n busy\n }) {\n const name = connector.title || connector.id;\n const authorization = safeHttpHref(connector.authorizationUrl);\n return /* @__PURE__ */ u3(\"div\", { class: \"card\", children: [\n /* @__PURE__ */ u3(\"div\", { class: \"connector-head\", children: [\n /* @__PURE__ */ u3(\"div\", { children: [\n /* @__PURE__ */ u3(\"div\", { class: \"connector-title\", children: [\n /* @__PURE__ */ u3(\"span\", { class: `dot ${connector.status}`, \"aria-hidden\": \"true\" }),\n /* @__PURE__ */ u3(\"h2\", { children: name })\n ] }),\n connector.description ? /* @__PURE__ */ u3(\"p\", { class: \"connector-description meta\", children: connector.description }) : null\n ] }),\n /* @__PURE__ */ u3(\"div\", { class: \"connector-state cap\", children: [\n connectorStatusLabel(connector.status),\n \" ·\",\n \" \",\n toolCountLabel(connector.toolCount),\n /* @__PURE__ */ u3(\"br\", {}),\n /* @__PURE__ */ u3(\"span\", { class: \"mono\", children: connector.id }),\n /* @__PURE__ */ u3(\"br\", {}),\n /* @__PURE__ */ u3(\"span\", { children: connector.authScope === \"personal\" ? \"personal auth\" : \"shared auth\" })\n ] })\n ] }),\n connector.message ? /* @__PURE__ */ u3(\"p\", { class: \"connector-message msg\", children: connector.message }) : null,\n connector.authorizationUrl ? /* @__PURE__ */ u3(\"p\", { class: authorization ? \"connector-auth\" : \"connector-auth meta\", children: authorization ? /* @__PURE__ */ u3(\n \"a\",\n {\n class: \"linklike\",\n href: authorization,\n target: \"_blank\",\n rel: \"noopener\",\n children: \"Authorize connector →\"\n }\n ) : `Authorization URL: ${connector.authorizationUrl}` }) : null,\n /* @__PURE__ */ u3(DriftPanel, { connector }),\n connector.catalogAccess ? /* @__PURE__ */ u3(\"p\", { class: \"meta\", children: [\n \"Last agent catalog read · \",\n connector.catalogAccess.state,\n \" ·\",\n \" \",\n new Date(connector.catalogAccess.observedAt).toLocaleString()\n ] }) : null,\n connector.oauth && oauthManagement ? /* @__PURE__ */ u3(\"div\", { class: \"credential-actions\", children: [\n /* @__PURE__ */ u3(\n \"button\",\n {\n type: \"button\",\n class: \"linklike danger\",\n \"aria-label\": `Disconnect OAuth for ${name}`,\n disabled: busy,\n onClick: () => void oauthAction(connector.id, \"disconnect\"),\n children: \"Disconnect OAuth\"\n }\n ),\n /* @__PURE__ */ u3(\n \"button\",\n {\n type: \"button\",\n class: \"linklike\",\n \"aria-label\": `${connector.status === \"ok\" ? \"Reconnect OAuth for\" : \"Restart authorization for\"} ${name}`,\n disabled: busy,\n onClick: () => void oauthAction(connector.id, \"reconnect\"),\n children: connector.status === \"ok\" ? \"Reconnect OAuth\" : \"Restart authorization\"\n }\n )\n ] }) : null,\n connector.credential ? /* @__PURE__ */ u3(\"p\", { class: \"connector-auth\", children: /* @__PURE__ */ u3(PageLink, { page: \"credentials\", class: \"linklike\", children: \"Manage credential →\" }) }) : null,\n tools.length ? /* @__PURE__ */ u3(\"details\", { open: expanded, children: [\n /* @__PURE__ */ u3(\"summary\", { class: \"linklike\", children: [\n \"Show tools (\",\n tools.length,\n \")\"\n ] }),\n /* @__PURE__ */ u3(\"div\", { class: \"tool-list\", children: tools.map((tool) => /* @__PURE__ */ u3(\"div\", { class: \"tool\", children: [\n /* @__PURE__ */ u3(\"code\", { children: tool.address }),\n tool.description ? /* @__PURE__ */ u3(\"span\", { class: \"td\", children: tool.description }) : null\n ] }, tool.address)) })\n ] }) : null\n ] });\n }\n function ConnectionsPage({ state: state2 }) {\n const data = state2.data;\n const query = state2.connectorFilter.trim();\n const filtered = data ? filterUiConnectors(data.connectors, query) : [];\n return /* @__PURE__ */ u3(\"section\", { id: \"connectionsView\", children: [\n /* @__PURE__ */ u3(\"div\", { class: \"lead pgrid\", children: [\n /* @__PURE__ */ u3(\"h1\", { id: \"connectionsHeading\", class: \"pcap\", tabIndex: -1, children: \"Connections\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"pbody lead-copy\", children: [\n /* @__PURE__ */ u3(\"p\", { children: \"Use this endpoint to give an MCP client access to the tools below.\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"endpoint\", children: /* @__PURE__ */ u3(\"div\", { class: \"endpoint-row\", children: [\n /* @__PURE__ */ u3(\"code\", { id: \"mcpUrl\", class: \"mono\", children: mcpUrl }),\n /* @__PURE__ */ u3(CopyButton, { value: mcpUrl, label: \"Copy URL\" })\n ] }) }),\n /* @__PURE__ */ u3(\"p\", { class: \"cap\", id: \"serverInfo\", children: data ? `${data.serverInfo?.name || productName} v${data.connectaVersion || \"?\"}` : productOperatorLabel }),\n /* @__PURE__ */ u3(NoticeLine, { id: \"oauthNotice\", notice: state2.oauthNotice })\n ] })\n ] }),\n /* @__PURE__ */ u3(\"section\", { class: \"section pgrid\", \"aria-labelledby\": \"connectorLedgerHeading\", children: [\n /* @__PURE__ */ u3(\"h2\", { class: \"pcap\", id: \"connectorLedgerHeading\", children: \"Connectors\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"pbody\", children: [\n /* @__PURE__ */ u3(\"div\", { class: \"row toolbar\", children: /* @__PURE__ */ u3(\n \"input\",\n {\n id: \"filter\",\n type: \"search\",\n placeholder: \"Filter connectors or tools…\",\n \"aria-label\": \"Filter connectors or tools\",\n value: state2.connectorFilter,\n onInput: (event) => setConnectorFilter(event.currentTarget.value)\n }\n ) }),\n /* @__PURE__ */ u3(\n \"div\",\n {\n id: \"list\",\n class: \"connector-tools\",\n \"aria-busy\": state2.refreshing || !data ? \"true\" : \"false\",\n children: !data ? /* @__PURE__ */ u3(Empty, { children: \"Loading connectors…\" }) : filtered.length === 0 ? /* @__PURE__ */ u3(Empty, { children: query ? \"No connectors or tools match this filter.\" : \"No connectors are declared in this deployment.\" }) : filtered.map(({ connector, tools }) => /* @__PURE__ */ u3(\n ConnectorCard,\n {\n connector,\n tools,\n expanded: Boolean(query),\n oauthManagement: data.oauthManagement,\n busy: state2.oauthBusy === connector.id\n },\n connector.id\n ))\n }\n )\n ] })\n ] })\n ] });\n }\n\n // src/operator-ui/app/credentials.tsx\n function CredentialForm({\n connector,\n credential,\n busy\n }) {\n const fields = credential.fields ?? [];\n const [values, setValues] = d2({});\n const single = fields.length === 0;\n const inputId = `credential-input-${connector}`;\n const submit = () => {\n if (single) {\n const value = (values.value ?? \"\").trim();\n if (!value) return refuseCredential(\"Paste a credential before saving.\");\n return void saveCredential(connector, { value });\n }\n const entries = {};\n for (const field of fields) {\n const value = (values[field.name] ?? \"\").trim();\n if (!value) {\n return refuseCredential(\n \"Complete every credential field before saving.\"\n );\n }\n entries[field.name] = value;\n }\n void saveCredential(connector, { values: entries });\n };\n return /* @__PURE__ */ u3(\"div\", { class: \"credential-form\", \"data-credential-form\": connector, children: [\n single ? /* @__PURE__ */ u3(S, { children: [\n /* @__PURE__ */ u3(\"label\", { class: \"visually-hidden\", for: inputId, children: credential.label }),\n /* @__PURE__ */ u3(\n \"input\",\n {\n id: inputId,\n type: \"password\",\n \"aria-label\": credential.label,\n placeholder: credential.placeholder || \"Paste credential\",\n autocomplete: \"new-password\",\n autocapitalize: \"none\",\n spellcheck: false,\n value: values.value ?? \"\",\n onInput: (event) => setValues({ value: event.currentTarget.value })\n }\n )\n ] }) : /* @__PURE__ */ u3(\"div\", { class: \"credential-fields\", children: fields.map((field, index) => {\n const id = `credential-input-${connector}-${index}`;\n return /* @__PURE__ */ u3(\"div\", { class: \"credential-field\", children: [\n /* @__PURE__ */ u3(\"label\", { for: id, children: field.label }),\n /* @__PURE__ */ u3(\n \"input\",\n {\n id,\n type: field.inputType || \"password\",\n placeholder: field.placeholder || field.label,\n autocomplete: (field.inputType ?? \"password\") === \"password\" ? \"new-password\" : \"off\",\n autocapitalize: \"none\",\n spellcheck: false,\n value: values[field.name] ?? \"\",\n onInput: (event) => setValues({\n ...values,\n [field.name]: event.currentTarget.value\n })\n }\n )\n ] }, field.name);\n }) }),\n /* @__PURE__ */ u3(\"button\", { class: \"linklike\", type: \"button\", disabled: busy, onClick: submit, children: busy ? \"Saving…\" : \"Save\" }),\n /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n disabled: busy,\n onClick: () => editCredential(null),\n children: \"Cancel\"\n }\n )\n ] });\n }\n function CredentialCard({\n connector,\n credential,\n editing,\n busy\n }) {\n const configured = Boolean(credential.configured);\n const removable = configured || Boolean(credential.removable);\n return /* @__PURE__ */ u3(\n \"section\",\n {\n class: \"credential-card\",\n id: `credential-${connector.id}`,\n \"aria-labelledby\": `credential-title-${connector.id}`,\n children: [\n /* @__PURE__ */ u3(\"div\", { class: \"credential-head\", children: [\n /* @__PURE__ */ u3(\"div\", { class: \"connector-title\", children: [\n /* @__PURE__ */ u3(\n \"span\",\n {\n class: `dot ${configured ? \"ok\" : \"auth_required\"}`,\n \"aria-hidden\": \"true\"\n }\n ),\n /* @__PURE__ */ u3(\"h2\", { id: `credential-title-${connector.id}`, children: connector.title || connector.id })\n ] }),\n /* @__PURE__ */ u3(\"span\", { class: \"credential-state\", children: credentialStateLabel(credential) })\n ] }),\n /* @__PURE__ */ u3(\"p\", { class: \"mono\", children: [\n connector.id,\n \" · \",\n connector.authScope === \"personal\" ? \"personal\" : \"shared\",\n \" · \",\n credential.label\n ] }),\n credential.description ? /* @__PURE__ */ u3(\"p\", { class: \"credential-copy meta\", children: credential.description }) : null,\n credential.fields?.length ? /* @__PURE__ */ u3(\"div\", { class: \"credential-field-summary\", children: credential.fields.map((field) => /* @__PURE__ */ u3(\"div\", { children: [\n /* @__PURE__ */ u3(\"span\", { children: field.label }),\n /* @__PURE__ */ u3(\"span\", { class: \"meta\", children: field.configured ? `configured · ••••${field.lastFour ?? \"\"}${field.updatedAt ? ` · updated ${formatDate(field.updatedAt)}` : \"\"}` : \"not configured\" })\n ] }, field.name)) }) : null,\n credential.error ? /* @__PURE__ */ u3(\"div\", { class: \"msg\", children: credential.error }) : null,\n credential.notice ? /* @__PURE__ */ u3(\"p\", { class: \"credential-copy meta\", children: credential.notice }) : null,\n /* @__PURE__ */ u3(\"div\", { class: \"credential-actions\", children: [\n /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n disabled: busy,\n onClick: () => editCredential(editing ? null : connector.id),\n children: removable ? \"Replace\" : \"Add credential\"\n }\n ),\n configured && credential.testable ? /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n disabled: busy,\n onClick: () => void testCredential(connector.id),\n children: busy ? \"Working…\" : \"Test\"\n }\n ) : null,\n removable ? /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike danger\",\n type: \"button\",\n disabled: busy,\n onClick: () => void removeCredential(connector.id),\n children: \"Remove\"\n }\n ) : null\n ] }),\n editing ? /* @__PURE__ */ u3(\n CredentialForm,\n {\n connector: connector.id,\n credential,\n busy\n }\n ) : null\n ]\n }\n );\n }\n function CredentialsPage({ state: state2 }) {\n const data = state2.data;\n const available = data?.credentialManagement === \"available\";\n const slots = (data?.connectors ?? []).filter(\n (connector) => Boolean(connector.credential)\n );\n return /* @__PURE__ */ u3(\"section\", { id: \"credentialsView\", children: /* @__PURE__ */ u3(\"div\", { class: \"lead pgrid\", children: [\n /* @__PURE__ */ u3(\"h1\", { id: \"credentialsHeading\", class: \"pcap\", tabIndex: -1, children: \"Credentials\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"pbody\", children: [\n /* @__PURE__ */ u3(\"p\", { class: \"activity-copy\", children: \"Manage shared or personal connector credentials. Stored values are never returned or displayed.\" }),\n /* @__PURE__ */ u3(NoticeLine, { id: \"credentialNotice\", notice: state2.credentialNotice }),\n !available ? /* @__PURE__ */ u3(Unavailable, { children: credentialUnavailableCopy(data?.credentialManagement) }) : /* @__PURE__ */ u3(\n \"div\",\n {\n id: \"credentialList\",\n class: \"credential-ledger\",\n \"aria-busy\": state2.credentialBusy ? \"true\" : \"false\",\n children: slots.length === 0 ? /* @__PURE__ */ u3(Empty, { children: \"No connector in this deployment declares a credential slot yet.\" }) : slots.map((connector) => /* @__PURE__ */ u3(\n CredentialCard,\n {\n connector,\n credential: connector.credential,\n editing: state2.credentialEditing === connector.id,\n busy: state2.credentialBusy === connector.id\n },\n connector.id\n ))\n }\n )\n ] })\n ] }) });\n }\n\n // src/operator-ui/app/tokens.tsx\n function CreateForm({ busy }) {\n const [name, setName] = d2(\"\");\n return /* @__PURE__ */ u3(\n \"form\",\n {\n id: \"tokenCreateForm\",\n class: \"token-create\",\n onSubmit: (event) => {\n event.preventDefault();\n void createAccessToken(name.trim()).then((created) => {\n if (created) setName(\"\");\n });\n },\n children: [\n /* @__PURE__ */ u3(\"label\", { for: \"tokenName\", children: \"Client name\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"row\", children: [\n /* @__PURE__ */ u3(\n \"input\",\n {\n id: \"tokenName\",\n type: \"text\",\n maxLength: 80,\n placeholder: \"Claude desktop, ChatGPT production…\",\n autocomplete: \"off\",\n value: name,\n onInput: (event) => setName(event.currentTarget.value)\n }\n ),\n /* @__PURE__ */ u3(\"button\", { id: \"createToken\", class: \"linklike\", type: \"submit\", disabled: busy, children: busy ? \"Creating…\" : \"Create token\" })\n ] })\n ]\n }\n );\n }\n function Reveal({ token }) {\n return /* @__PURE__ */ u3(\n \"section\",\n {\n id: \"tokenReveal\",\n class: \"token-reveal\",\n \"aria-labelledby\": \"tokenRevealHeading\",\n children: [\n /* @__PURE__ */ u3(\"div\", { class: \"token-reveal-head\", children: [\n /* @__PURE__ */ u3(\"h2\", { id: \"tokenRevealHeading\", tabIndex: -1, children: \"Copy this token now\" }),\n /* @__PURE__ */ u3(\"span\", { class: \"cap\", children: \"Shown once\" })\n ] }),\n /* @__PURE__ */ u3(\"p\", { class: \"meta\", children: \"Store it in the MCP client before leaving this page. It cannot be displayed again.\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"endpoint-row token-secret\", children: [\n /* @__PURE__ */ u3(\"code\", { id: \"createdToken\", class: \"mono\", children: token }),\n /* @__PURE__ */ u3(CopyButton, { value: token, label: \"Copy token\" })\n ] }),\n /* @__PURE__ */ u3(\"button\", { class: \"linklike\", type: \"button\", onClick: dismissCreatedToken, children: \"I stored it\" })\n ]\n }\n );\n }\n function TokenCard({\n token,\n renaming,\n busy\n }) {\n const [name, setName] = d2(token.name);\n const revoked = Boolean(token.revokedAt);\n return /* @__PURE__ */ u3(\n \"section\",\n {\n class: revoked ? \"token-card revoked\" : \"token-card\",\n \"aria-labelledby\": `access-token-${token.id}`,\n children: [\n /* @__PURE__ */ u3(\"div\", { class: \"token-card-head\", children: [\n /* @__PURE__ */ u3(\"div\", { children: [\n /* @__PURE__ */ u3(\"h2\", { id: `access-token-${token.id}`, children: token.name }),\n /* @__PURE__ */ u3(\"p\", { class: \"mono\", children: [\n token.tokenPrefix,\n \"…\"\n ] })\n ] }),\n /* @__PURE__ */ u3(\"div\", { class: \"cap\", children: revoked ? `Revoked ${formatDate(token.revokedAt)}` : `Created ${formatDate(token.createdAt)}` })\n ] }),\n /* @__PURE__ */ u3(\"div\", { class: \"credential-actions\", children: [\n /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n disabled: busy,\n onClick: () => {\n setName(token.name);\n renameAccessToken(renaming ? null : token.id);\n },\n children: \"Rename\"\n }\n ),\n revoked ? null : /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike danger\",\n type: \"button\",\n disabled: busy,\n onClick: () => void revokeAccessToken(token.id),\n children: \"Revoke\"\n }\n )\n ] }),\n renaming ? /* @__PURE__ */ u3(\n \"form\",\n {\n class: \"credential-form\",\n onSubmit: (event) => {\n event.preventDefault();\n const next = name.trim();\n if (next) void saveAccessTokenName(token.id, next);\n },\n children: [\n /* @__PURE__ */ u3(\"label\", { class: \"visually-hidden\", for: `token-name-${token.id}`, children: \"Token name\" }),\n /* @__PURE__ */ u3(\n \"input\",\n {\n id: `token-name-${token.id}`,\n type: \"text\",\n maxLength: 80,\n autocomplete: \"off\",\n value: name,\n onInput: (event) => setName(event.currentTarget.value)\n }\n ),\n /* @__PURE__ */ u3(\"button\", { class: \"linklike\", type: \"submit\", disabled: busy, children: \"Save name\" }),\n /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n disabled: busy,\n onClick: () => renameAccessToken(null),\n children: \"Cancel\"\n }\n )\n ]\n }\n ) : null\n ]\n }\n );\n }\n function TokensPage({ state: state2 }) {\n const available = state2.data?.accessTokenManagement === \"available\";\n return /* @__PURE__ */ u3(\"section\", { id: \"tokensView\", children: /* @__PURE__ */ u3(\"div\", { class: \"lead pgrid\", children: [\n /* @__PURE__ */ u3(\"h1\", { id: \"tokensHeading\", class: \"pcap\", tabIndex: -1, children: \"Access tokens\" }),\n /* @__PURE__ */ u3(\"div\", { class: \"pbody\", children: [\n /* @__PURE__ */ u3(\"p\", { class: \"activity-copy\", children: \"Create named Bearer tokens for MCP clients. Each secret is shown once; revoke it when that client should lose access.\" }),\n /* @__PURE__ */ u3(NoticeLine, { id: \"tokenNotice\", notice: state2.tokenNotice }),\n !available ? /* @__PURE__ */ u3(Unavailable, { children: accessTokenUnavailableCopy(state2.data?.accessTokenManagement) }) : /* @__PURE__ */ u3(\"div\", { id: \"tokenAvailable\", children: [\n state2.createdToken ? /* @__PURE__ */ u3(Reveal, { token: state2.createdToken }) : /* @__PURE__ */ u3(CreateForm, { busy: state2.tokenBusy }),\n /* @__PURE__ */ u3(\n \"div\",\n {\n id: \"tokenList\",\n class: \"token-ledger\",\n \"aria-busy\": state2.tokenPhase === \"loading\" ? \"true\" : \"false\",\n children: state2.tokenPhase === \"loading\" ? /* @__PURE__ */ u3(Empty, { children: \"Loading access tokens…\" }) : state2.tokenPhase === \"error\" ? /* @__PURE__ */ u3(\"p\", { class: \"empty\", children: /* @__PURE__ */ u3(\n \"button\",\n {\n class: \"linklike\",\n type: \"button\",\n onClick: () => void loadAccessTokens(),\n children: \"Try loading access tokens again\"\n }\n ) }) : state2.tokens.length === 0 ? /* @__PURE__ */ u3(Empty, { children: \"No access tokens yet. Name the first MCP client above.\" }) : state2.tokens.map((token) => /* @__PURE__ */ u3(\n TokenCard,\n {\n token,\n renaming: state2.tokenRenaming === token.id,\n busy: state2.tokenBusy\n },\n token.id\n ))\n }\n )\n ] })\n ] })\n ] }) });\n }\n\n // src/operator-ui/app/main.tsx\n function useOperatorState() {\n const [, bump] = y2((count) => count + 1, 0);\n const snapshot = getState();\n _2(() => {\n const unsubscribe = subscribe(() => bump(void 0));\n if (getState() !== snapshot) bump(void 0);\n return unsubscribe;\n }, []);\n return snapshot;\n }\n function visiblePages(state2) {\n return OPERATOR_PAGES.filter((page) => {\n if (page === \"credentials\") {\n return state2.data?.credentialManagement === \"available\";\n }\n if (page === \"tokens\") {\n return state2.data?.accessTokenManagement === \"available\";\n }\n if (page === \"activity\") return Boolean(state2.data?.activityEnabled);\n return true;\n });\n }\n function OperatorNav() {\n const state2 = useOperatorState();\n if (state2.session !== \"ready\") return null;\n return /* @__PURE__ */ u3(\"div\", { class: \"mast-actions\", children: [\n /* @__PURE__ */ u3(\"nav\", { class: \"page-nav\", \"aria-label\": \"Operator pages\", children: visiblePages(state2).map((page) => /* @__PURE__ */ u3(\n PageLink,\n {\n page,\n class: \"navlink\",\n current: state2.page === page,\n children: PAGE_META[page].label\n },\n page\n )) }),\n /* @__PURE__ */ u3(\"div\", { class: \"session-actions\", \"aria-label\": \"Session actions\", children: auth.kind === \"clerk\" || auth.kind === \"cloudflare-access\" ? /* @__PURE__ */ u3(\"button\", { class: \"navlink\", type: \"button\", onClick: signOut, children: \"Sign out\" }) : /* @__PURE__ */ u3(\"button\", { class: \"navlink\", type: \"button\", onClick: forgetBearer, children: \"Change token\" }) })\n ] });\n }\n function Gate({ state: state2 }) {\n const [token, setToken] = d2(\"\");\n const signedIn = auth.kind === \"clerk\" && Boolean(window.Clerk?.user);\n const loading = state2.session === \"loading\";\n return /* @__PURE__ */ u3(\"section\", { id: \"gate\", children: /* @__PURE__ */ u3(\"div\", { class: \"lead pgrid\", children: [\n /* @__PURE__ */ u3(\"h1\", { id: \"gateHeading\", class: \"pcap\", tabIndex: -1, children: PAGE_META[state2.page].label }),\n /* @__PURE__ */ u3(\"div\", { class: \"pbody lead-copy\", children: [\n /* @__PURE__ */ u3(\"p\", { children: productDescription }),\n /* @__PURE__ */ u3(\"p\", { id: \"gateCopy\", class: \"meta\", children: loading ? \"Checking your session…\" : gateCopy(auth.kind, signedIn) }),\n loading ? null : auth.kind === \"clerk\" ? /* @__PURE__ */ u3(\"div\", { id: \"clerkGate\", class: \"actions gate-actions\", children: signedIn ? /* @__PURE__ */ u3(\"button\", { class: \"linklike\", type: \"button\", onClick: signOut, children: \"Sign out\" }) : /* @__PURE__ */ u3(\"button\", { id: \"signin\", class: \"linklike\", type: \"button\", onClick: signIn, children: \"Team sign in\" }) }) : auth.kind === \"cloudflare-access\" ? /* @__PURE__ */ u3(\"div\", { class: \"actions gate-actions\", children: /* @__PURE__ */ u3(\"button\", { class: \"linklike\", type: \"button\", onClick: signOut, children: \"Sign out of Cloudflare Access\" }) }) : /* @__PURE__ */ u3(\n \"form\",\n {\n id: \"tokenGate\",\n class: \"row gate-actions\",\n onSubmit: (event) => {\n event.preventDefault();\n const value = token.trim();\n if (!value) return;\n setToken(\"\");\n signInWithBearer(value);\n },\n children: [\n /* @__PURE__ */ u3(\n \"input\",\n {\n id: \"token\",\n type: \"password\",\n placeholder: \"Bearer token\",\n autocomplete: \"off\",\n \"aria-label\": \"Bearer token\",\n value: token,\n onInput: (event) => setToken(event.currentTarget.value)\n }\n ),\n /* @__PURE__ */ u3(\"button\", { id: \"save\", class: \"linklike\", type: \"submit\", children: \"Open operator pages\" })\n ]\n }\n ),\n /* @__PURE__ */ u3(NoticeLine, { id: \"err\", notice: state2.gate, className: \"\" })\n ] })\n ] }) });\n }\n function CurrentPage({ state: state2 }) {\n if (state2.page === \"credentials\") return /* @__PURE__ */ u3(CredentialsPage, { state: state2 });\n if (state2.page === \"tokens\") return /* @__PURE__ */ u3(TokensPage, { state: state2 });\n if (state2.page === \"activity\") return /* @__PURE__ */ u3(ActivityPage, { state: state2 });\n return /* @__PURE__ */ u3(ConnectionsPage, { state: state2 });\n }\n function OperatorApp() {\n const state2 = useOperatorState();\n const ready = state2.session === \"ready\";\n h2(() => {\n document.title = `${PAGE_META[state2.page].label} — ${titleSuffix}`;\n }, [state2.page]);\n h2(() => {\n if (!ready) return;\n if (state2.page === \"tokens\" && state2.data?.accessTokenManagement === \"available\" && state2.tokenPhase === \"idle\") {\n void loadAccessTokens();\n }\n if (state2.page === \"activity\" && state2.data?.activityEnabled && state2.activityPhase === \"idle\") {\n void loadActivity(true);\n }\n });\n h2(() => {\n if (!state2.pendingFocus) return;\n document.getElementById(state2.pendingFocus)?.focus();\n focusHandled();\n }, [state2.pendingFocus]);\n return ready ? /* @__PURE__ */ u3(\"div\", { id: \"app\", children: /* @__PURE__ */ u3(CurrentPage, { state: state2 }) }) : /* @__PURE__ */ u3(Gate, { state: state2 });\n }\n function mount(id, view) {\n const host = document.getElementById(id);\n if (!host) return;\n host.textContent = \"\";\n R(view, host);\n }\n mount(\"operatorNav\", /* @__PURE__ */ u3(OperatorNav, {}));\n mount(\"operatorContent\", /* @__PURE__ */ u3(OperatorApp, {}));\n void boot();\n})();\n"; diff --git a/src/operator-ui/model.ts b/src/operator-ui/model.ts index aac2b1fd..c327a220 100644 --- a/src/operator-ui/model.ts +++ b/src/operator-ui/model.ts @@ -42,6 +42,8 @@ interface UiCredential { export interface UiConnector { id: string; + /** Downstream authentication owner. Older payload fixtures omit it as shared. */ + authScope?: "shared" | "personal"; title?: string; description?: string; status: "ok" | "auth_required" | "error"; @@ -49,7 +51,7 @@ export interface UiConnector { authorizationUrl?: string; toolCount: number; tools: UiTool[]; - /** This connector exposes operator-managed downstream OAuth lifecycle hooks. */ + /** This connector exposes manageable downstream OAuth lifecycle hooks. */ oauth?: boolean; credential?: UiCredential; /** @@ -84,7 +86,7 @@ export interface UiData { activityEnabled: boolean; credentialManagement: CredentialManagementCapability; accessTokenManagement: AccessTokenManagementCapability; - /** True only for an eligible interactive operator. */ + /** True when this interactive human may manage any visible OAuth connector. */ oauthManagement: boolean; } diff --git a/src/operator-ui/view.ts b/src/operator-ui/view.ts index 9e8fa593..ce07563d 100644 --- a/src/operator-ui/view.ts +++ b/src/operator-ui/view.ts @@ -223,7 +223,7 @@ export function credentialUnavailableCopy( if (capability === "vault_not_configured") { return "Credential storage is not configured. Set credentials.encryptionKey before managing connector credentials here."; } - return "Credential management requires an eligible interactive operator. Bearer-authenticated sessions can inspect connections but cannot manage stored credentials."; + return "Credential management requires an interactive user. Bearer-authenticated sessions can inspect connections but cannot manage stored credentials."; } export function accessTokenUnavailableCopy( diff --git a/src/providers/cloudflare.ts b/src/providers/cloudflare.ts index 2ae4f5c9..2fa86b39 100644 --- a/src/providers/cloudflare.ts +++ b/src/providers/cloudflare.ts @@ -61,6 +61,8 @@ export const CLOUDFLARE_CONTENT_DNS_RECORD_TYPES = [ export interface CloudflareOptions { /** Human-readable display name; defaults to "Cloudflare". */ title?: string; + /** Downstream auth ownership. Defaults to one shared deployment grant. */ + authScope?: "shared" | "personal"; /** Which account/estate this connection administers, and for whom. */ purpose: string; /** @@ -3605,6 +3607,7 @@ export function cloudflare(id: string, options: CloudflareOptions): Connector { zoneId: options.zoneId?.trim() || undefined, }; return api(id, { + ...(options.authScope ? { authScope: options.authScope } : {}), title: options.title ?? "Cloudflare", description: `Cloudflare control-plane access for zones, DNS, Workers, KV, R2, Pages, media, email, and other v4 APIs — ${purpose}`, credential: credentialConfig(authentication, options.credential), diff --git a/src/providers/linear.ts b/src/providers/linear.ts index 6295e991..805a0809 100644 --- a/src/providers/linear.ts +++ b/src/providers/linear.ts @@ -30,6 +30,8 @@ export interface LinearOptions { * "Linear (read-only)" when `access` is `"read-only"`. */ title?: string; + /** Downstream auth ownership. Defaults to one shared deployment grant. */ + authScope?: "shared" | "personal"; /** Which workspace this is and what decisions it answers. */ purpose: string; /** Required endpoint selection; see `documentation/linear.md`. */ @@ -207,6 +209,7 @@ export function linear(id: string, options: LinearOptions): Connector { } const connector = remoteMcp(id, { url: LINEAR_MCP_ENDPOINTS[access], + ...(options.authScope ? { authScope: options.authScope } : {}), // The title is what browse-time discovery renders; a read-only connection // says so there rather than only in a description the caller may not see. title: diff --git a/src/providers/mixpanel.ts b/src/providers/mixpanel.ts index b06e1898..8a71fcb3 100644 --- a/src/providers/mixpanel.ts +++ b/src/providers/mixpanel.ts @@ -27,6 +27,8 @@ export interface MixpanelOptions { * discovery shows the title before anything else. */ title?: string; + /** Downstream auth ownership. Defaults to one shared deployment grant. */ + authScope?: "shared" | "personal"; /** Who should use this account and for what decisions. */ purpose: string; /** Data residency region; see `documentation/mixpanel.md`. */ @@ -249,6 +251,7 @@ export function mixpanel(id: string, options: MixpanelOptions): Connector { } const connector = remoteMcp(id, { url: MIXPANEL_MCP_ENDPOINTS[region], + ...(options.authScope ? { authScope: options.authScope } : {}), // The region rides the title because browse-time discovery renders the // title and the guide summary and nothing else, and residency is the fact // an agent must not get wrong between two Mixpanel connections. diff --git a/src/providers/notion.ts b/src/providers/notion.ts index a51b4f5b..21791658 100644 --- a/src/providers/notion.ts +++ b/src/providers/notion.ts @@ -56,6 +56,8 @@ const NOTION_ADMISSION: ConnectorCallAdmissionPolicy = { export interface NotionOptions { /** Human-readable display name; defaults to "Notion". */ title?: string; + /** Downstream auth ownership. Defaults to one shared deployment grant. */ + authScope?: "shared" | "personal"; /** Which workspace this is and what it should be used for. Required. */ purpose: string; /** Workspace-specific conventions appended to the maintained provider guide. */ @@ -1752,6 +1754,7 @@ export function notion(id: string, options: NotionOptions): Connector { } return api(id, { + ...(options.authScope ? { authScope: options.authScope } : {}), title: options.title ?? "Notion", description: `Notion workspace — ${purpose}`, credential: { diff --git a/src/providers/revenuecat.ts b/src/providers/revenuecat.ts index d56a1db9..6f628ed6 100644 --- a/src/providers/revenuecat.ts +++ b/src/providers/revenuecat.ts @@ -16,6 +16,8 @@ export const REVENUECAT_MCP_ENDPOINT = "https://mcp.revenuecat.ai/mcp"; export interface RevenueCatOptions { /** Display name; scope defaults are in `documentation/revenuecat.md`. */ title?: string; + /** Downstream auth ownership. Defaults to one shared deployment grant. */ + authScope?: "shared" | "personal"; /** * Which project this connector is for and what decisions it answers. With * headers auth this is the only place the project a key reaches is named, @@ -263,6 +265,7 @@ export function revenuecat(id: string, options: RevenueCatOptions): Connector { const scoped = auth.type !== "oauth"; const connector = remoteMcp(id, { url: REVENUECAT_MCP_ENDPOINT, + ...(options.authScope ? { authScope: options.authScope } : {}), // The scope shape rides the title because browse-time discovery renders // the title and the guide summary and nothing else, and reaching one // project versus every project the account has is the fact an agent must diff --git a/src/providers/stripe.ts b/src/providers/stripe.ts index 2ce9a1bb..908c71e5 100644 --- a/src/providers/stripe.ts +++ b/src/providers/stripe.ts @@ -19,6 +19,8 @@ export const STRIPE_MCP_ENDPOINT = "https://mcp.stripe.com/"; interface StripeCommonOptions { /** Human-readable display name; defaults to "Stripe" for OAuth. */ title?: string; + /** Downstream auth ownership. Defaults to one shared deployment grant. */ + authScope?: "shared" | "personal"; /** Which business purpose and Stripe context this connector is for. */ purpose: string; /** Connector-specific conventions appended to the maintained provider guide. */ @@ -282,6 +284,7 @@ export function stripe(id: string, options: StripeOptions): Connector { const copy = mode === undefined ? undefined : MODE_COPY[mode]; const connector = remoteMcp(id, { url: STRIPE_MCP_ENDPOINT, + ...(options.authScope ? { authScope: options.authScope } : {}), title: options.title ?? copy?.title ?? "Stripe", description: mode === undefined diff --git a/src/registry.ts b/src/registry.ts index 69d0eab7..7d028af2 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -151,6 +151,10 @@ export interface RegistryOptions { storage: KVStorage; logger: Logger; credentialVault?: CredentialVault | undefined; + /** Internal owner partition used by a personal registry. */ + credentialOwner?: string | undefined; + /** Internal child registries skip deployment-wide construction warnings. */ + constructionChecks?: boolean | undefined; toolCacheTtlSeconds?: number | undefined; persistToolCatalog?: boolean | undefined; toolCatalogStaleSeconds?: number | undefined; @@ -175,6 +179,14 @@ function namespaced(storage: KVStorage, prefix: string): KVStorage { get: (k) => storage.get(prefix + k), set: (k, v, o) => storage.set(prefix + k, v, o), delete: (k) => storage.delete(prefix + k), + ...(storage.list + ? { + list: async (keyPrefix: string) => + (await storage.list!(prefix + keyPrefix)).map((key) => + key.slice(prefix.length), + ), + } + : {}), }; } @@ -246,6 +258,26 @@ export interface RegistryView { callOptions?: ConnectorOperationOptions, ): Promise; invalidateStored(id: string): Promise; + /** Bind returned OAuth state to this view's personal storage partition. */ + bindOAuthHandoff(id: string, authorizationUrl: string): Promise; +} + +export interface RegistryScope { + connectorIds: "all" | readonly string[]; + subjectKey?: string; + principalKey?: string; +} + +const MAX_PERSONAL_REGISTRIES = 1_024; +const OAUTH_HANDOFF_TTL_SECONDS = 15 * 60; + +async function sha256Hex(value: string): Promise { + const bytes = new Uint8Array( + await crypto.subtle.digest("SHA-256", encoder.encode(value)), + ); + return [...bytes] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); } /** @@ -289,11 +321,14 @@ export class Registry implements RegistryView { private readonly persistToolCatalog: boolean; /** Result-size guard cap threaded to the meta-tools. */ readonly maxResultBytes: number; + private readonly configuredConnectors: Connector[]; + private readonly personalRegistries = new Map(); constructor( connectors: Connector[], private readonly opts: RegistryOptions, ) { + this.configuredConnectors = [...connectors]; this.observedOutputSchemas = new ObservedOutputSchemas(); this.ttlMs = (opts.toolCacheTtlSeconds ?? DEFAULT_TTL_SECONDS) * 1000; @@ -313,6 +348,15 @@ export class Registry implements RegistryView { if (this.connectors.has(c.id)) { throw new Error(`Duplicate connector id "${c.id}"`); } + if ( + c.authScope !== undefined && + c.authScope !== "shared" && + c.authScope !== "personal" + ) { + throw new Error( + `Invalid authScope on connector "${c.id}": expected "shared" or "personal"`, + ); + } const configuredGuideSummary = typeof c.usageGuide === "object" ? normalizeGuideSummary(c.usageGuide.summary ?? "") @@ -336,8 +380,116 @@ export class Registry implements RegistryView { ); } } - this.checkConventions(opts.logger); - this.checkResultCaps(opts.logger, opts.maxResultBytes); + if (opts.constructionChecks !== false) { + this.checkConventions(opts.logger); + this.checkResultCaps(opts.logger, opts.maxResultBytes); + } + } + + personalRegistry(principalKey: string): Registry { + const existing = this.personalRegistries.get(principalKey); + if (existing) { + this.personalRegistries.delete(principalKey); + this.personalRegistries.set(principalKey, existing); + return existing; + } + const registry = new Registry( + this.configuredConnectors.filter( + (connector) => connector.authScope === "personal", + ), + { + ...this.opts, + storage: namespaced(this.opts.storage, `principal:${principalKey}:`), + credentialOwner: principalKey, + constructionChecks: false, + }, + ); + this.personalRegistries.set(principalKey, registry); + const oldest = this.personalRegistries.keys().next().value; + if ( + this.personalRegistries.size > MAX_PERSONAL_REGISTRIES && + typeof oldest === "string" + ) { + this.personalRegistries.delete(oldest); + } + return registry; + } + + /** Build the only connector view an authenticated request receives. */ + scoped(scope: RegistryScope): RegistryView { + const requested = scope.connectorIds === "all" + ? new Set(this.connectors.keys()) + : new Set(scope.connectorIds); + for (const id of requested) { + if (!this.connectors.has(id)) { + throw new Error( + `Identity access resolver returned unknown connector "${id}"`, + ); + } + } + return new ScopedRegistryView(this, requested, scope); + } + + scopedStorage(subjectKey: string): KVStorage { + return namespaced(this.opts.storage, `subject:${subjectKey}:`); + } + + private oauthHandoffKey(connectorId: string, stateHash: string): string { + return `oauth-handoff:v1:${connectorId}:${stateHash}`; + } + + async storeOAuthHandoff( + connectorId: string, + state: string, + principalKey: string, + ): Promise { + const key = this.oauthHandoffKey(connectorId, await sha256Hex(state)); + const existing = await this.opts.storage.get(key); + if (existing && existing !== principalKey) { + throw new Error( + `Connector "${connectorId}" reused one OAuth state across principals`, + ); + } + await this.opts.storage.set( + key, + principalKey, + { ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS }, + ); + } + + async oauthCallbackView( + connectorId: string, + state: string | null, + ): Promise<{ + registry: RegistryView; + principalKey?: string; + } | null> { + const connector = this.connectors.get(connectorId); + if (!connector) return null; + if (connector.authScope !== "personal") return { registry: this }; + if (!state) return null; + const principalKey = await this.opts.storage.get( + this.oauthHandoffKey(connectorId, await sha256Hex(state)), + ); + if (!principalKey) return null; + return { + registry: this.scoped({ + connectorIds: [connectorId], + subjectKey: principalKey, + principalKey, + }), + principalKey, + }; + } + + async clearOAuthHandoff( + connectorId: string, + state: string | null, + ): Promise { + if (!state) return; + await this.opts.storage.delete( + this.oauthHandoffKey(connectorId, await sha256Hex(state)), + ); } /** @@ -427,7 +579,7 @@ export class Registry implements RegistryView { if (this.opts.credentialVault && credentialConfig) { const vault = this.opts.credentialVault; const readValues = async () => { - const values = await vault.getAll(id); + const values = await vault.getAll(id, this.opts.credentialOwner); const shape = storedCredentialShape(credentialConfig, values); if (shape.state === "mismatch") { throw new ConnectorCallError("auth_required", shape.message); @@ -547,6 +699,10 @@ export class Registry implements RegistryView { return namespaced(this.opts.storage, "results:"); } + async bindOAuthHandoff(): Promise { + // Shared OAuth already resolves in deployment-wide connector storage. + } + observedOutputSchema( connectorId: string, definition: ToolDef, @@ -1264,7 +1420,7 @@ export class Registry implements RegistryView { const vault = this.opts.credentialVault; if (!credential || !vault) return undefined; try { - const values = await vault.getAll(id); + const values = await vault.getAll(id, this.opts.credentialOwner); const shape = storedCredentialShape(credential, values); return shape.state === "mismatch" ? shape.message : undefined; } catch (error) { @@ -1356,3 +1512,140 @@ export class Registry implements RegistryView { if (this.persistToolCatalog) await this.deleteStoredCatalog(id); } } + +class ScopedRegistryView implements RegistryView { + readonly maxResultBytes: number; + private readonly personal: Registry | undefined; + + constructor( + private readonly root: Registry, + private readonly allowed: ReadonlySet, + private readonly scope: RegistryScope, + ) { + this.maxResultBytes = root.maxResultBytes; + this.personal = scope.principalKey + ? root.personalRegistry(scope.principalKey) + : undefined; + } + + private registryFor(id: string): Registry | undefined { + if (!this.allowed.has(id)) return undefined; + const connector = this.root.getConnector(id); + if (!connector) return undefined; + return connector.authScope === "personal" ? this.personal : this.root; + } + + listConnectors(): Connector[] { + return this.root.listConnectors().filter( + (connector) => this.registryFor(connector.id) !== undefined, + ); + } + + getConnector(id: string): Connector | undefined { + return this.registryFor(id)?.getConnector(id); + } + + resolveAddress( + address: string, + ): { connector: Connector; toolName: string } | null { + const parsed = splitAddress(address); + if (!parsed) return null; + const connector = this.getConnector(parsed.connectorId); + return connector ? { connector, toolName: parsed.toolName } : null; + } + + getTools(...args: Parameters): Promise { + const registry = this.registryFor(args[0]); + if (!registry) { + return Promise.reject(new Error(`Unknown connector "${args[0]}"`)); + } + return registry.getTools(...args); + } + + contextFor( + ...args: Parameters + ): ConnectorContext { + const registry = this.registryFor(args[0]); + if (!registry) throw new Error(`Unknown connector "${args[0]}"`); + return registry.contextFor(...args); + } + + admitCall( + ...args: Parameters + ): Promise { + if (!this.registryFor(args[0])) { + return Promise.reject(new Error(`Unknown connector "${args[0]}"`)); + } + return this.root.admitCall(...args); + } + + resultsStorage(): KVStorage { + return this.scope.subjectKey + ? this.root.scopedStorage(this.scope.subjectKey) + : this.root.resultsStorage(); + } + + credentialDriftFor(id: string): Promise { + const registry = this.registryFor(id); + return registry + ? registry.credentialDriftFor(id) + : Promise.resolve(undefined); + } + + observedOutputSchema( + connectorId: string, + definition: ToolDef, + ): ToolDef["outputSchema"] | undefined { + return this.registryFor(connectorId)?.observedOutputSchema( + connectorId, + definition, + ); + } + + observeOutputShape( + connectorId: string, + definition: ToolDef, + value: unknown, + ): void { + this.registryFor(connectorId)?.observeOutputShape( + connectorId, + definition, + value, + ); + } + + statusFor( + ...args: Parameters + ): Promise { + const registry = this.registryFor(args[0]); + return registry + ? registry.statusFor(...args) + : Promise.resolve({ state: "error", message: "Unknown connector" }); + } + + invalidateStored(id: string): Promise { + const registry = this.registryFor(id); + return registry ? registry.invalidateStored(id) : Promise.resolve(); + } + + async bindOAuthHandoff( + id: string, + authorizationUrl: string, + ): Promise { + const connector = this.getConnector(id); + if (connector?.authScope !== "personal" || !this.scope.principalKey) return; + let state: string | null = null; + try { + state = new URL(authorizationUrl).searchParams.get("state"); + } catch { + return; + } + if (state) { + await this.root.storeOAuthHandoff( + id, + state, + this.scope.principalKey, + ); + } + } +} diff --git a/src/routes/access-tokens.ts b/src/routes/access-tokens.ts index d463b314..52688db9 100644 --- a/src/routes/access-tokens.ts +++ b/src/routes/access-tokens.ts @@ -79,6 +79,7 @@ export async function routeAccessTokens( opts.auth, "access token management", context.runtimeContext, + opts.identity, ); if (!admin.ok) return admin.response; @@ -91,7 +92,10 @@ export async function routeAccessTokens( const input = await readName(request); if (!input.ok) return input.response; return privateJson( - await opts.accessTokens.create(input.name, admin.userId), + await opts.accessTokens.create( + input.name, + admin.principal ?? admin.userId, + ), { status: 201 }, ); } diff --git a/src/routes/activity.ts b/src/routes/activity.ts index 8d1704c9..885f41e1 100644 --- a/src/routes/activity.ts +++ b/src/routes/activity.ts @@ -174,8 +174,18 @@ export async function routeActivity( if (request.method !== "GET") { return privateJson({ error: "method not allowed" }, { status: 405 }); } - const authz = await authorize(request, baseUrl, opts.auth, runtimeContext); + const authz = await authorize( + request, + baseUrl, + opts.auth, + runtimeContext, + opts.identity, + false, + ); if (!authz.ok) return authz.response; + if (opts.identity?.operatorAccess && !authz.operator) { + return privateJson({ error: "operator access required" }, { status: 403 }); + } if ( opts.activityReadGate && !(await opts.activityReadGate(authz.actor)) diff --git a/src/routes/credentials.ts b/src/routes/credentials.ts index ec3b26e5..0e5fc14e 100644 --- a/src/routes/credentials.ts +++ b/src/routes/credentials.ts @@ -8,7 +8,7 @@ import type { ConnectorCredentialValues, } from "../types.js"; import { - authorizeUiAdmin, + authorizeUiIdentity, isSameOrigin, msg, privateJson, @@ -134,19 +134,38 @@ async function handleCredentialRequest( { status: 403 }, ); } - const admin = await authorizeUiAdmin( + const authz = await authorizeUiIdentity( request, baseUrl, opts.auth, "credential management", context.runtimeContext, + opts.identity, ); - if (!admin.ok) return admin.response; + if (!authz.ok) return authz.response; + let registry; + try { + registry = opts.registry.scoped({ + connectorIds: authz.connectorIds, + ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}), + ...(authz.principalKey ? { principalKey: authz.principalKey } : {}), + }); + } catch (error) { + return privateJson({ error: msg(error) }, { status: 403 }); + } - const connector = opts.registry.getConnector(connectorId); + const connector = registry.getConnector(connectorId); if (!connector?.credential) { return privateJson({ error: "unknown credential slot" }, { status: 404 }); } + const personal = connector.authScope === "personal"; + if (personal && !authz.principalKey) { + return privateJson({ error: "forbidden" }, { status: 403 }); + } + const owner = personal ? authz.principalKey : undefined; + const updatedBy = authz.identity.principal + ? `${authz.identity.principal.namespace}:${authz.identity.principal.id}` + : authz.actor.id ?? authz.actor.kind; if (action === "test") { if (request.method !== "POST") { @@ -168,7 +187,7 @@ async function handleCredentialRequest( ); } try { - const values = await opts.credentialVault.getAll(connectorId); + const values = await opts.credentialVault.getAll(connectorId, owner); const shape = storedCredentialShape(connector.credential, values); if (shape.state === "missing") { return privateJson( @@ -185,7 +204,7 @@ async function handleCredentialRequest( return privateJson({ error: shape.message }, { status: 409 }); } const storedValues = values!; - const ctx = opts.registry.contextFor(connectorId, baseUrl); + const ctx = registry.contextFor(connectorId, baseUrl); const result = rule.mode === "multiple" ? await connector.testCredentials!(storedValues, ctx) @@ -212,22 +231,24 @@ async function handleCredentialRequest( ? await opts.credentialVault.set( connectorId, input.input.value, - admin.userId, + updatedBy, + owner, ) : await opts.credentialVault.setAll( connectorId, input.input.values, - admin.userId, + updatedBy, + owner, ); - await opts.registry.invalidateStored(connectorId); + await registry.invalidateStored(connectorId); return privateJson({ credential: metadata }); } catch (err) { return privateJson({ error: msg(err) }, { status: 400 }); } } if (request.method === "DELETE") { - await opts.credentialVault.delete(connectorId); - await opts.registry.invalidateStored(connectorId); + await opts.credentialVault.delete(connectorId, owner); + await registry.invalidateStored(connectorId); return new Response(null, { status: 204, headers: { diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts index 6f87ee13..24fe7ca5 100644 --- a/src/routes/mcp.ts +++ b/src/routes/mcp.ts @@ -19,6 +19,7 @@ import { import { registerMetaTools } from "../meta-tools.js"; import type { RegistryView } from "../registry.js"; import { instructionsFor } from "../skills.js"; +import { msg } from "../errors.js"; import type { Logger } from "../types.js"; import { authorize, @@ -403,6 +404,7 @@ export function createMcpRoute( baseUrl, opts.auth, runtimeContext, + opts.identity, ); if (!authz.ok) { return releaseAdmissionWithResponse( @@ -411,6 +413,25 @@ export function createMcpRoute( request.signal, ); } + let scopedRegistry: RegistryView; + try { + scopedRegistry = opts.registry.scoped({ + connectorIds: authz.connectorIds, + ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}), + ...(authz.principalKey ? { principalKey: authz.principalKey } : {}), + }); + } catch (error) { + return releaseAdmissionWithResponse( + withMcpCors( + new Response(JSON.stringify({ error: msg(error) }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }), + ), + admission, + request.signal, + ); + } if (new URL(request.url).searchParams.has("toolkit")) { return releaseAdmissionWithResponse( withMcpCors(toolkitRetired(opts.logger)), @@ -425,7 +446,7 @@ export function createMcpRoute( opts, baseUrl, authz.actor, - opts.registry, + scopedRegistry, runtimeContext, ), ), diff --git a/src/routes/oauth.ts b/src/routes/oauth.ts index e9c3d531..87358290 100644 --- a/src/routes/oauth.ts +++ b/src/routes/oauth.ts @@ -7,7 +7,7 @@ import type { } from "../types.js"; import { isSafeHttpUrl, resolveBranding } from "../ui.js"; import { - authorizeUiAdmin, + authorizeUiIdentity, isSameOrigin, loggableValue, msg, @@ -26,28 +26,42 @@ async function handleOAuthManagementRequest( { status: 403 }, ); } - const admin = await authorizeUiAdmin( + const authz = await authorizeUiIdentity( request, baseUrl, opts.auth, "OAuth management", context.runtimeContext, + opts.identity, ); - if (!admin.ok) return admin.response; + if (!authz.ok) return authz.response; + let registry; + try { + registry = opts.registry.scoped({ + connectorIds: authz.connectorIds, + ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}), + ...(authz.principalKey ? { principalKey: authz.principalKey } : {}), + }); + } catch (error) { + return privateJson({ error: msg(error) }, { status: 403 }); + } - const connector = opts.registry.getConnector(connectorId); + const connector = registry.getConnector(connectorId); if (!connector?.disconnectAuth || !connector.startAuth) { return privateJson( { error: "unknown OAuth connector" }, { status: 404 }, ); } + if (connector.authScope === "personal" && !authz.principalKey) { + return privateJson({ error: "forbidden" }, { status: 403 }); + } if (request.method !== "DELETE" && request.method !== "POST") { return privateJson({ error: "method not allowed" }, { status: 405 }); } const requestScope = {}; - const ctx = opts.registry.contextFor(connectorId, baseUrl, requestScope); + const ctx = registry.contextFor(connectorId, baseUrl, requestScope); try { let result: ConnectorStatus | undefined; let operationError: unknown; @@ -56,6 +70,12 @@ async function handleOAuthManagementRequest( await connector.disconnectAuth(ctx); } else { result = await connector.startAuth(ctx, { force: true }); + if (result.authorizationUrl) { + await registry.bindOAuthHandoff( + connectorId, + result.authorizationUrl, + ); + } } } catch (error) { operationError = error; @@ -64,7 +84,7 @@ async function handleOAuthManagementRequest( // The old grant and its cached catalog are invalid after either operation, // including a partially failed physical cleanup whose epoch fence succeeded. try { - await opts.registry.invalidateStored(connectorId); + await registry.invalidateStored(connectorId); } catch (error) { operationError ??= error; } @@ -252,13 +272,18 @@ export async function routeOAuthCallback( const code = url.searchParams.get("code"); if (!code) return html("Missing authorization code.", 400, opts.branding); const id = path.slice("/oauth/callback/".length); - const connector = opts.registry.getConnector(id); + const state = url.searchParams.get("state"); + const callbackTarget = await opts.registry.oauthCallbackView(id, state); + const callbackRegistry = callbackTarget?.registry; + const connector = callbackRegistry?.getConnector(id); // Safe to build before we know the id names anything: `contextFor` is a pure // constructor — a namespaced storage view over `conn::` and, only for a // connector that declares one, a lazy credential accessor. It neither throws // nor touches storage for an unknown id, which is what lets the refusals // below borrow it to equalize their cost. - const connectorContext = opts.registry.contextFor(id, baseUrl); + const connectorContext = callbackRegistry + ? callbackRegistry.contextFor(id, baseUrl) + : opts.registry.contextFor(id, baseUrl); const refused = () => html( "Authorization could not be completed. Re-run authorization from " + @@ -270,6 +295,29 @@ export async function routeOAuthCallback( await equalizeRefusalCost(connectorContext); return refused(); } + const expectedPrincipalKey = callbackTarget?.principalKey; + if (expectedPrincipalKey) { + const browserIdentity = await authorizeUiIdentity( + context.request, + baseUrl, + opts.auth, + "OAuth callback", + context.runtimeContext, + opts.identity, + ); + if ( + browserIdentity.ok && + browserIdentity.principalKey !== expectedPrincipalKey + ) { + opts.logger.warn( + `[connecta] refused an OAuth callback for connector ` + + `${loggableValue(id)} with 400: the authenticated browser identity ` + + "did not start this personal authorization flow. No authorization " + + "code was exchanged.", + ); + return refused(); + } + } // CSRF / login-fixation guard: this route is intentionally public, so verify // the `state` matches the flow connecta started BEFORE exchanging the code. if (!connector.verifyState) { @@ -283,7 +331,6 @@ export async function routeOAuthCallback( ); return refused(); } - const state = url.searchParams.get("state"); let stateMatches: boolean; try { stateMatches = await connector.verifyState(state, connectorContext); @@ -309,9 +356,21 @@ export async function routeOAuthCallback( ); return refused(); } + if (connector.authScope === "personal") { + try { + await opts.registry.clearOAuthHandoff(id, state); + } catch (err) { + opts.logger.warn( + `[connecta] refused an OAuth callback for connector ` + + `${loggableValue(id)} with 500: its principal handoff could not be ` + + `consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`, + ); + return html("Authorization could not be completed.", 500, opts.branding); + } + } try { await connector.finishAuth(code, connectorContext, url.searchParams); - await opts.registry.invalidateStored(id); + await callbackRegistry!.invalidateStored(id); return html( `Connected "${id}". You can close this window.`, 200, diff --git a/src/routes/shared.ts b/src/routes/shared.ts index 94866ac6..957d78cd 100644 --- a/src/routes/shared.ts +++ b/src/routes/shared.ts @@ -6,18 +6,23 @@ import type { DeferredWork } from "../connector-scope.js"; import type { AdmissionController } from "../executor-admission.js"; import type { Registry } from "../registry.js"; import type { + AuthenticatedIdentity, ConnectaBranding, Executor, + IdentityReference, InboundAuth, InboundAuthRuntimeContext, Logger, } from "../types.js"; +import { identityStorageKey, validIdentityReference } from "../identity.js"; +import type { ConnectaIdentityConfig } from "../index.js"; import { operatorPageForPath } from "../ui.js"; export { msg } from "../errors.js"; export interface ServerOptions { registry: Registry; auth: InboundAuth[]; + identity?: ConnectaIdentityConfig | undefined; publicUrl?: string | undefined; // The SDK's Implementation shape: name/version plus optional title, // websiteUrl, and icons (MCP icons spec) that clients may render. @@ -112,17 +117,35 @@ export async function authorize( baseUrl: string, auth: InboundAuth[], runtimeContext?: RuntimeExecutionContext, + identityConfig?: ConnectaIdentityConfig, + partitionIdentity = true, ): Promise< | { ok: true; actor: ActivityActor; - /** True only when the admitting provider can also authorize UI mutation. */ + identity: AuthenticatedIdentity; + subjectKey?: string; + principalKey?: string; + connectorIds: "all" | readonly string[]; + operator: boolean; + /** Backward-compatible name used by operator views. */ uiAdminEligible?: boolean; } | { ok: false; response: Response } > { if (auth.length === 0) { - return { ok: true, actor: { kind: "anonymous" } }; + const actor = { kind: "anonymous" } as const; + const identity: AuthenticatedIdentity = { actor, interactive: false }; + let connectorIds: "all" | readonly string[] = "all"; + try { + connectorIds = await identityConfig?.connectorAccess?.(identity) ?? "all"; + } catch { + return { + ok: false, + response: privateJson({ error: "identity access resolution failed" }, { status: 403 }), + }; + } + return { ok: true, actor, identity, connectorIds, operator: false }; } let lastResponse: Response | null = null; for (const provider of auth) { @@ -130,18 +153,58 @@ export async function authorize( if (result.ok) { const subjectId = result.subjectId ?? result.userId; const actorNamespace = activityActorNamespace(provider); + const subject = subjectId && actorNamespace + ? { namespace: actorNamespace, id: subjectId } + : undefined; + const derivedPrincipal = result.userId && actorNamespace + ? { namespace: actorNamespace, id: result.userId } + : undefined; + const principal = validIdentityReference(result.principal) + ? result.principal + : derivedPrincipal; + const interactive = Boolean(result.userId && provider.interactiveOperator); + const actor: ActivityActor = { + kind: provider.kind, + ...(subjectId ? { id: subjectId } : {}), + ...(subject ? { namespace: subject.namespace } : {}), + }; + const identity: AuthenticatedIdentity = { + actor, + ...(subject ? { subject } : {}), + ...(principal ? { principal } : {}), + interactive, + }; + let operator = interactive; + let connectorIds: "all" | readonly string[] = "all"; + try { + if (identityConfig?.operatorAccess) { + operator = interactive && principal + ? await identityConfig.operatorAccess(principal) + : false; + } + connectorIds = await identityConfig?.connectorAccess?.(identity) ?? "all"; + } catch { + return { + ok: false, + response: privateJson( + { error: "identity access resolution failed" }, + { status: 403 }, + ), + }; + } return { ok: true, - actor: { - kind: provider.kind, - ...(subjectId ? { id: subjectId } : {}), - ...(subjectId && actorNamespace - ? { namespace: actorNamespace } - : {}), - }, - ...(result.userId && provider.interactiveOperator - ? { uiAdminEligible: true } + actor, + identity, + ...(subject && partitionIdentity + ? { subjectKey: await identityStorageKey(subject) } + : {}), + ...(principal && partitionIdentity + ? { principalKey: await identityStorageKey(principal) } : {}), + connectorIds, + operator, + ...(operator ? { uiAdminEligible: true } : {}), }; } lastResponse = result.response; @@ -166,7 +229,17 @@ export async function authorizeUiAdmin( auth: InboundAuth[], purpose = "credential management", runtimeContext?: RuntimeExecutionContext, -): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> { + identityConfig?: ConnectaIdentityConfig, +): Promise< + | { + ok: true; + userId: string; + principal?: IdentityReference; + principalKey?: string; + connectorIds: "all" | readonly string[]; + } + | { ok: false; response: Response } +> { // Operator mutation is intentionally narrower than /mcp and /ui/data: only // an interactive provider may admit it. A static bearer token is useful // for headless tool calls but must not become a deployment-admin key. @@ -175,37 +248,78 @@ export async function authorizeUiAdmin( // Stopping at the first would make admission depend on config order: a failed gate or // missing user may simply mean a later provider is the one meant to admit. // The last refusal is returned if none do. + const authz = await authorizeUiIdentity( + request, + baseUrl, + auth, + purpose, + runtimeContext, + identityConfig, + ); + if (!authz.ok) return authz; + if (!authz.operator || !authz.actor.id) { + return { + ok: false, + response: privateJson({ error: `${purpose} requires operator access` }, { status: 403 }), + }; + } + return { + ok: true, + userId: authz.identity.principal?.id ?? authz.actor.id, + ...(authz.identity.principal + ? { principal: authz.identity.principal } + : {}), + ...(authz.principalKey ? { principalKey: authz.principalKey } : {}), + connectorIds: authz.connectorIds, + }; +} + +export async function authorizeUiIdentity( + request: Request, + baseUrl: string, + auth: InboundAuth[], + purpose: string, + runtimeContext?: RuntimeExecutionContext, + identityConfig?: ConnectaIdentityConfig, +): Promise< + | Extract>, { ok: true }> + | { ok: false; response: Response } +> { const providers = auth.filter((candidate) => candidate.interactiveOperator); if (providers.length === 0) { return { ok: false, response: privateJson( - { error: `${purpose} requires interactive operator authentication` }, + { error: `${purpose} requires interactive user authentication` }, { status: 403 }, ), }; } - let lastResponse: Response | null = null; + let lastResponse: Response | undefined; for (const provider of providers) { - const result = await provider.authorize(request, baseUrl, runtimeContext); - if (!result.ok) { - lastResponse = result.response; + const authz = await authorize( + request, + baseUrl, + [provider], + runtimeContext, + identityConfig, + ); + if (!authz.ok) { + lastResponse = authz.response; continue; } - if (!result.userId) { - lastResponse = privateJson( - { error: "authenticated user required" }, - { status: 403 }, - ); - continue; - } - return { ok: true, userId: result.userId }; + if (authz.identity.interactive) return authz; + lastResponse = privateJson( + { error: "authenticated user required" }, + { status: 403 }, + ); } return { ok: false, - response: - lastResponse ?? - privateJson({ error: "forbidden" }, { status: 403 }), + response: lastResponse ?? privateJson( + { error: `${purpose} requires interactive user authentication` }, + { status: 403 }, + ), }; } diff --git a/src/routes/ui.ts b/src/routes/ui.ts index c890e411..f71586f7 100644 --- a/src/routes/ui.ts +++ b/src/routes/ui.ts @@ -8,6 +8,7 @@ import { } from "../ui.js"; import { authorize, + msg, privateJson, type RouteContext, } from "./shared.js"; @@ -124,16 +125,45 @@ export async function routeUi( } if (path !== "/ui/data") return null; - const authz = await authorize(request, baseUrl, opts.auth, runtimeContext); + const authz = await authorize( + request, + baseUrl, + opts.auth, + runtimeContext, + opts.identity, + ); if (!authz.ok) return authz.response; + let registry; + try { + registry = opts.registry.scoped({ + connectorIds: authz.connectorIds, + ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}), + ...(authz.principalKey ? { principalKey: authz.principalKey } : {}), + }); + } catch (error) { + return privateJson({ error: msg(error) }, { status: 403 }); + } const eligibleOperator = authz.uiAdminEligible === true; - const credentialManagement = credentialManagementCapability({ - eligibleOperator, - hasCredentialSlots: opts.registry - .listConnectors() - .some((connector) => Boolean(connector.credential)), - hasCredentialVault: Boolean(opts.credentialVault), - }); + const interactiveManager = authz.identity.interactive; + const personalManager = Boolean( + interactiveManager && authz.principalKey, + ); + const visibleConnectors = registry.listConnectors(); + const hasManageableCredentialSlot = visibleConnectors.some( + (connector) => Boolean(connector.credential) && + (connector.authScope !== "personal" || personalManager), + ); + const credentialManagement = interactiveManager && hasManageableCredentialSlot + ? opts.credentialVault + ? "available" as const + : "vault_not_configured" as const + : credentialManagementCapability({ + eligibleOperator: interactiveManager, + hasCredentialSlots: visibleConnectors.some((connector) => + Boolean(connector.credential) + ), + hasCredentialVault: Boolean(opts.credentialVault), + }); // As with connector credentials, a Bearer-authenticated observer learns // only that an interactive operator is required, not whether this deployment has opted into // token issuance. Configuration topology is operator data. @@ -143,18 +173,20 @@ export async function routeUi( ? "available" as const : "not_configured" as const; const data = await buildUiData( - opts.registry, + registry, baseUrl, opts.serverInfo, - // The static headless bearer may read connector health, but only a - // Clerk-authenticated operator receives credential metadata. - eligibleOperator ? opts.credentialVault : undefined, - Boolean(opts.activity?.list), + // A static headless bearer may read connector health, but only an + // interactive human receives credential metadata for visible connectors. + interactiveManager ? opts.credentialVault : undefined, + Boolean(opts.activity?.list) && + (!opts.identity?.operatorAccess || eligibleOperator), credentialManagement, defer, - eligibleOperator, + interactiveManager, opts.discoveryConcurrency, accessTokenManagement, + personalManager ? authz.principalKey : undefined, ); return privateJson(data); } diff --git a/src/types.ts b/src/types.ts index 096526f8..aa563151 100644 --- a/src/types.ts +++ b/src/types.ts @@ -158,7 +158,7 @@ export interface ConnectorContext { /** Public base URL of this deployment (origin), used for OAuth callbacks. */ baseUrl: string; /** - * Read-only access to this connector's operator-managed credential. Present + * Read-only access to this connector's human-managed credential. Present * only when the connector declares `credential` and the deployment configures * `credentials.encryptionKey`. */ @@ -234,6 +234,12 @@ export interface ConnectorStatus { /** The whole plugin contract — the one open seam. */ export interface Connector { id: string; // address prefix; [a-z0-9_-]+ + /** + * Who owns this connector's downstream authentication. `shared` keeps one + * deployment-wide grant. `personal` isolates storage and credentials by the + * authenticated human principal. Defaults to `shared`. + */ + authScope?: "shared" | "personal"; /** Human-readable display name; the stable `id` remains the tool-address prefix. */ title?: string; /** How call_tool wraps results. "mcp" passes the content array through; anything else is JSON-wrapped. */ @@ -266,7 +272,7 @@ export interface Connector { * configuration; no runtime registration or shared mutable copy exists. */ usageGuide?: string | ConnectorUsageGuide; - /** Optional operator-managed credential slot rendered on /credentials. */ + /** Optional human-managed credential slot rendered on /credentials. */ credential?: ConnectorCredentialConfig; /** Optional server-side check used by /credentials' Test action. */ testCredential?( @@ -471,9 +477,31 @@ export type AuthResult = ok: true; userId?: string; subjectId?: string; + /** Human owner represented by a non-interactive access credential. */ + principal?: IdentityReference; } | { ok: false; response: Response }; +/** Stable identity inside one configured authentication directory. */ +export interface IdentityReference { + namespace: string; + id: string; +} + +/** Identity data passed to config-owned access resolvers. */ +export interface AuthenticatedIdentity { + actor: { + kind: string; + id?: string; + namespace?: string; + }; + /** Any stable admitted caller, including service identities and tokens. */ + subject?: IdentityReference; + /** Human owner of personal connector authentication. */ + principal?: IdentityReference; + interactive: boolean; +} + /** Public browser-auth configuration exposed to connecta's status UI. */ export type UiAuthConfig = | { diff --git a/src/ui.ts b/src/ui.ts index a224c7d1..73918f0b 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -23,7 +23,7 @@ import { OPERATOR_UI_CSS, OPERATOR_UI_SCRIPT, } from "./operator-ui/generated.js"; -import type { Registry } from "./registry.js"; +import type { RegistryView } from "./registry.js"; import type { ConnectaBranding, ConnectorStatus, @@ -291,7 +291,7 @@ export function credentialManagementCapability(input: { * failing the whole payload. */ export async function buildUiData( - registry: Registry, + registry: RegistryView, baseUrl: string, serverInfo: { name: string; version: string }, credentialVault?: CredentialVault, @@ -303,6 +303,7 @@ export async function buildUiData( oauthManagement = false, discoveryConcurrency?: number, accessTokenManagement: AccessTokenManagementCapability = "not_configured", + personalCredentialOwner?: string, ): Promise { const requestScope = {}; const connectorSet = registry.listConnectors(); @@ -315,6 +316,9 @@ export async function buildUiData( const status: ConnectorStatus = drift ? { state: "auth_required", message: drift } : await registry.statusFor(c.id, baseUrl, requestScope); + if (status.authorizationUrl) { + await registry.bindOAuthHandoff(c.id, status.authorizationUrl); + } let tools: UiTool[] = []; // `status()` on an unauthenticated remote connector starts OAuth and // stores its state + PKCE verifier. Probing listTools immediately @@ -337,7 +341,10 @@ export async function buildUiData( } } let credential: UiConnector["credential"]; - if (c.credential && credentialVault) { + const mayManageAuth = c.authScope === "personal" + ? Boolean(personalCredentialOwner) + : oauthManagement; + if (c.credential && credentialVault && mayManageAuth) { // One rule, shared with the test route: only the hook matching the // declared credential shape can run, so the button is offered only // where a click can succeed (src/credentials.ts). @@ -376,7 +383,10 @@ export async function buildUiData( : {}), }; try { - const metadata = await credentialVault.metadata(c.id); + const metadata = await credentialVault.metadata( + c.id, + c.authScope === "personal" ? personalCredentialOwner : undefined, + ); const fields = credentialFields(metadata); const shape = storedCredentialShape( c.credential, @@ -423,6 +433,7 @@ export async function buildUiData( } return { id: c.id, + authScope: c.authScope ?? "shared", ...(c.title ? { title: c.title } : {}), ...(c.description !== undefined ? { description: c.description } @@ -442,7 +453,13 @@ export async function buildUiData( ...(status.catalogAccess ? { catalogAccess: status.catalogAccess } : {}), - ...(c.disconnectAuth && c.startAuth ? { oauth: true } : {}), + ...(c.disconnectAuth && + c.startAuth && + (oauthManagement || + c.authScope === "personal" || + !personalCredentialOwner) + ? { oauth: true } + : {}), ...(credential ? { credential } : {}), }; }, @@ -468,7 +485,7 @@ export async function buildUiData( activityEnabled, credentialManagement, accessTokenManagement, - oauthManagement, + oauthManagement: oauthManagement || Boolean(personalCredentialOwner), }; } diff --git a/templates/node/README.md b/templates/node/README.md index eed3f675..0993725d 100644 --- a/templates/node/README.md +++ b/templates/node/README.md @@ -68,6 +68,13 @@ import, the two `process.env.CLERK_*` reads, and the `clerkAuth({ … })` entry Applications → DCR) if MCP clients should sign in through it too, and set `PUBLIC_URL` first — Clerk redirects back to it. +Clerk remains the identity provider when several people share this Docker +deployment. Uncomment the `identity` block in `src/index.ts` to give each Clerk +principal a config-derived connector view and to choose operators. Add +`authScope: "personal"` to a connector when each person should supply their own +credential or finish their own downstream OAuth flow. Without those options, +all connectors and auth stay shared exactly as before. + **2. Credential vault.** Uncomment `credentials` and set `CONNECTA_CREDENTIAL_KEY` to a base64 32-byte AES key: diff --git a/templates/node/src/index.ts b/templates/node/src/index.ts index 249ca14c..ae4f6a2f 100644 --- a/templates/node/src/index.ts +++ b/templates/node/src/index.ts @@ -42,10 +42,10 @@ const stateFile = process.env.CONNECTA_STATE_FILE || "./.connecta-state.json"; const publicUrl = process.env.PUBLIC_URL || `http://localhost:${port}`; // Operator sign-in. A bearer token is a client key: it may call tools and read -// connector status, but only a Clerk-authenticated operator may write a -// credential or issue an access token. Without this block the operator pages -// still render — an operator pastes the bearer to read them — and Credentials -// and Tokens stay read-only. +// connector status, but only a Clerk-authenticated human may write a visible +// connector's credential, and only an operator may issue an access token. +// Without this block the operator pages still render — an operator pastes the +// bearer to read them — and Credentials and Tokens stay read-only. // const clerkPublishableKey = process.env.CLERK_PUBLISHABLE_KEY; // const clerkSecretKey = process.env.CLERK_SECRET_KEY; // if (!clerkPublishableKey || !clerkSecretKey) { @@ -67,6 +67,15 @@ const connecta = createConnecta({ // // allowedDomains: ["acme.com"], // }), ], + // Optional member/operator split for Clerk-backed Docker deployments. + // Connector access is derived from the authenticated identity and cannot be + // selected by an MCP argument. Omit this block for the legacy all-visible, + // all-interactive-users-are-operators behavior. + // identity: { + // connectorAccess: ({ principal }) => + // principal?.id === "user_admin" ? "all" : ["time"], + // operatorAccess: ({ id }) => id === "user_admin", + // }, publicUrl, // Required: model-written programs run in a bounded QuickJS child. executor: quickJsExecutor(), diff --git a/test/config.test.ts b/test/config.test.ts index 34f71243..5388c53a 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -23,6 +23,10 @@ describe("ConnectaConfig boundary", () => { const config: ConnectaConfig = { connectors: [], auth: fakeClerkAuth(), + identity: { + connectorAccess: () => "all", + operatorAccess: () => true, + }, storage: memoryStorage(), publicUrl: "https://connecta.test", executor, @@ -197,6 +201,7 @@ describe("ConnectaConfig boundary", () => { it.each([ ["activity", { store: { record() {} }, typo: true }, "activity.typo"], + ["identity", { typo: true }, "identity.typo"], ["credentials", { typo: true }, "credentials.typo"], ["accessTokens", { typo: true }, "accessTokens.typo"], ["discovery", { typo: true }, "discovery.typo"], diff --git a/test/deployment-shapes.test.ts b/test/deployment-shapes.test.ts index bc43c70b..f86e2e29 100644 --- a/test/deployment-shapes.test.ts +++ b/test/deployment-shapes.test.ts @@ -29,6 +29,30 @@ describe("deployment shapes", () => { expect(options).toEqual(["loader: env.LOADER"]); }); + it("pins hosted MCP callbacks in the Worker deployment instructions", () => { + const worker = join(ROOT, "examples", "worker"); + const agents = readFileSync(join(worker, "AGENTS.md"), "utf8"); + const readme = readFileSync(join(worker, "README.md"), "utf8"); + const source = readFileSync(join(worker, "src", "index.ts"), "utf8"); + const callbacks = [ + "https://claude.ai/api/mcp/auth_callback", + "https://chatgpt.com/connector_platform_oauth_redirect", + "https://chatgpt.com/connector/oauth/*", + ]; + for (const callback of callbacks) { + expect(agents).toContain(callback); + expect(readme).toContain(callback); + expect(source).toContain(callback); + } + expect(agents).toContain( + "oauth_configuration.dynamic_client_registration.allowed_uris", + ); + expect(readme).toContain( + '"dynamic_client_registration": {', + ); + expect(readme).toContain('"allowed_uris": ['); + }); + it("ships one Node deployment that is also its own container", () => { expect(readdirSync(TEMPLATE).sort()).toEqual([ ".dockerignore", @@ -128,7 +152,10 @@ describe("deployment shapes", () => { join(ROOT, "examples", "worker", "src", "index.ts"), "utf8", ); - expect(worker).toContain("clerkAuth({"); + expect(worker).toContain("cloudflareAccessAuth()"); + expect(worker).not.toContain("clerkAuth"); + expect(worker).toContain("// identity: {"); + expect(worker).toContain('// Use `authScope: "personal"`'); expect(worker).toContain( "credentials: { encryptionKey: env.CREDENTIAL_ENCRYPTION_KEY },", ); diff --git a/test/identity-scope.test.ts b/test/identity-scope.test.ts new file mode 100644 index 00000000..6fc305e9 --- /dev/null +++ b/test/identity-scope.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; +import { api } from "../src/connectors/api.js"; +import { remoteMcp } from "../src/connectors/remote-mcp.js"; +import { memoryStorage } from "../src/storage/memory.js"; +import type { Connector, InboundAuth } from "../src/types.js"; +import { createTestConnecta } from "./helpers.js"; + +const BASE = "https://connecta.test"; +const ENCRYPTION_KEY = btoa(String.fromCharCode(...new Uint8Array(32).fill(7))); + +function users(): InboundAuth { + return { + kind: "test-users", + interactiveOperator: true, + activityActorNamespace: "https://identity.test", + authorize(request) { + const user = /^Bearer (alice|bob)$/u.exec( + request.headers.get("authorization") ?? "", + )?.[1]; + return user + ? { ok: true, userId: user, subjectId: user } + : { + ok: false, + response: Response.json({ error: "unauthorized" }, { status: 401 }), + }; + }, + }; +} + +function request( + path: string, + user: "alice" | "bob", + init: RequestInit = {}, +): Request { + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${user}`); + return new Request(`${BASE}${path}`, { ...init, headers }); +} + +function visible(id: string): Connector { + return api(id, { description: id, tools: [] }); +} + +describe("identity-scoped connectors", () => { + it("refuses static headers disguised as personal auth", () => { + expect(() => + remoteMcp("bad", { + url: "https://mcp.test", + authScope: "personal", + auth: { type: "headers", headers: { Authorization: "secret" } }, + }) + ).toThrow("cannot combine authScope \"personal\" with static headers"); + }); + + it("derives connector visibility from the authenticated principal", async () => { + const connecta = createTestConnecta({ + connectors: [visible("common"), visible("alice_only"), visible("bob_only")], + auth: users(), + identity: { + connectorAccess(identity) { + return identity.principal?.id === "alice" + ? ["common", "alice_only"] + : ["common", "bob_only"]; + }, + }, + storage: memoryStorage(), + publicUrl: BASE, + }); + + const alice = await connecta.fetch(request("/ui/data", "alice")); + const bob = await connecta.fetch(request("/ui/data", "bob")); + expect(((await alice.json()) as any).connectors.map((item: Connector) => item.id)) + .toEqual(["common", "alice_only"]); + expect(((await bob.json()) as any).connectors.map((item: Connector) => item.id)) + .toEqual(["common", "bob_only"]); + + const aliceResults = connecta.registry.scoped({ + connectorIds: "all", + subjectKey: "alice-subject", + }).resultsStorage(); + const bobResults = connecta.registry.scoped({ + connectorIds: "all", + subjectKey: "bob-subject", + }).resultsStorage(); + await aliceResults.set("result:id", "alice result"); + expect(await bobResults.get("result:id")).toBeNull(); + }); + + it("isolates personal vault entries while visible shared auth stays editable", async () => { + const personal = api("personal", { + description: "Personal connection", + authScope: "personal", + credential: { label: "Personal token" }, + tools: [], + }); + personal.status = async (ctx) => { + const value = await ctx.credential?.get(); + return value + ? { state: "ok", message: value.slice(-4) } + : { state: "auth_required" }; + }; + const shared = api("shared", { + description: "Shared connection", + credential: { label: "Shared token" }, + tools: [], + }); + const connecta = createTestConnecta({ + connectors: [personal, shared], + auth: users(), + identity: { + operatorAccess: (principal) => principal.id === "alice", + }, + credentials: { encryptionKey: ENCRYPTION_KEY }, + accessTokens: {}, + storage: memoryStorage(), + publicUrl: BASE, + }); + const put = (connector: string, user: "alice" | "bob", value: string) => + connecta.fetch( + request(`/ui/credentials/${connector}`, user, { + method: "PUT", + headers: { "Content-Type": "application/json", Origin: BASE }, + body: JSON.stringify({ value }), + }), + ); + + expect((await put("personal", "alice", "alice-secret-1111")).status).toBe(200); + expect((await put("personal", "bob", "bob-secret-2222")).status).toBe(200); + expect((await put("shared", "bob", "shared-secret-3333")).status).toBe(200); + expect((await put("shared", "alice", "shared-secret-4444")).status).toBe(200); + + const alice = await connecta.fetch(request("/ui/data", "alice")); + const bob = await connecta.fetch(request("/ui/data", "bob")); + const aliceData = await alice.json() as any; + const bobData = await bob.json() as any; + expect(aliceData.connectors.find((item: any) => item.id === "personal") + .credential.lastFour).toBe("1111"); + expect(bobData.connectors.find((item: any) => item.id === "personal") + .credential.lastFour).toBe("2222"); + expect(bobData.connectors.find((item: any) => item.id === "shared") + .credential.lastFour).toBe("4444"); + + const tokenResponse = await connecta.fetch( + request("/ui/access-tokens", "alice", { + method: "POST", + headers: { "Content-Type": "application/json", Origin: BASE }, + body: JSON.stringify({ name: "Alice agent" }), + }), + ); + const token = (await tokenResponse.json() as any).token as string; + const tokenView = await connecta.fetch( + new Request(`${BASE}/ui/data`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ); + const tokenData = await tokenView.json() as any; + expect(tokenData.accessTokenManagement).toBe("requires_operator"); + expect(tokenData.connectors.find((item: any) => item.id === "shared") + .credential).toBeUndefined(); + expect(tokenData.connectors.find((item: any) => item.id === "personal") + .message).toBe("1111"); + }); + + it("returns a personal OAuth callback to the principal that started it", async () => { + const oauth: Connector = { + id: "oauth", + description: "Personal OAuth connection", + authScope: "personal", + async listTools() { + return []; + }, + async callTool() {}, + async status(ctx) { + return await ctx.storage.get("token") + ? { state: "ok" } + : { state: "auth_required" }; + }, + async startAuth(ctx) { + const state = crypto.randomUUID(); + await ctx.storage.set("pending", state); + return { + state: "auth_required", + authorizationUrl: `https://provider.test/authorize?state=${state}`, + }; + }, + async disconnectAuth(ctx) { + await ctx.storage.delete("token"); + await ctx.storage.delete("pending"); + }, + async verifyState(state, ctx) { + return state !== null && state === await ctx.storage.get("pending"); + }, + async finishAuth(_code, ctx) { + await ctx.storage.set("token", "connected"); + await ctx.storage.delete("pending"); + }, + }; + const connecta = createTestConnecta({ + connectors: [oauth], + auth: users(), + identity: { operatorAccess: () => false }, + storage: memoryStorage(), + publicUrl: BASE, + }); + + const started = await connecta.fetch( + request("/ui/oauth/oauth", "alice", { + method: "POST", + headers: { Origin: BASE }, + }), + ); + expect(started.status).toBe(200); + const authorizationUrl = new URL((await started.json() as any).authorizationUrl); + const state = authorizationUrl.searchParams.get("state"); + const fixedByAnotherUser = await connecta.fetch( + request(`/oauth/callback/oauth?code=code&state=${state}`, "bob"), + ); + expect(fixedByAnotherUser.status).toBe(400); + const callback = await connecta.fetch( + new Request(`${BASE}/oauth/callback/oauth?code=code&state=${state}`), + ); + expect(callback.status).toBe(200); + const replay = await connecta.fetch( + new Request(`${BASE}/oauth/callback/oauth?code=code&state=${state}`), + ); + expect(replay.status).toBe(400); + + const alice = await connecta.fetch(request("/ui/data", "alice")); + const bob = await connecta.fetch(request("/ui/data", "bob")); + expect(((await alice.json()) as any).connectors[0].status).toBe("ok"); + expect(((await bob.json()) as any).connectors[0].status).toBe("auth_required"); + }); +}); diff --git a/test/meta-tools.test.ts b/test/meta-tools.test.ts index 280b45b3..bfca69ad 100644 --- a/test/meta-tools.test.ts +++ b/test/meta-tools.test.ts @@ -1018,8 +1018,9 @@ describe("authorize_connector", () => { operatorUrl: `${BASE}/credentials`, instructions: "Have the operator open operatorUrl, set and test the credential, " + - "then retry the original call. No redeploy is needed. Credential " + - "mutation requires a Clerk-authenticated operator.", + "then retry the original call. No redeploy is needed. Shared " + + "credential mutation requires a signed-in human with access to this " + + "connector.", }); expect(required(result.content[0]).text).not.toContain( "do-not-return-this-secret", diff --git a/test/operator-view.test.ts b/test/operator-view.test.ts index 481c3d3c..3e6ff680 100644 --- a/test/operator-view.test.ts +++ b/test/operator-view.test.ts @@ -394,7 +394,7 @@ describe("operator app state", () => { "credentials.encryptionKey", ); expect(credentialUnavailableCopy("requires_operator")).toContain( - "eligible interactive operator", + "interactive user", ); expect(accessTokenUnavailableCopy("not_configured")).toContain( "not configured for this deployment", diff --git a/test/server-route-contracts.test.ts b/test/server-route-contracts.test.ts index f359282d..900e56b0 100644 --- a/test/server-route-contracts.test.ts +++ b/test/server-route-contracts.test.ts @@ -273,7 +273,7 @@ describe("server route contracts", () => { expectPrivateJson(credentialWithoutClerk); expect(credentialWithoutClerk.status).toBe(403); expect(await credentialWithoutClerk.text()).toBe( - '{"error":"credential management requires interactive operator authentication"}', + '{"error":"credential management requires interactive user authentication"}', ); const oauthWithoutClerk = await connecta.fetch( @@ -288,7 +288,7 @@ describe("server route contracts", () => { expectPrivateJson(oauthWithoutClerk); expect(oauthWithoutClerk.status).toBe(403); expect(await oauthWithoutClerk.text()).toBe( - '{"error":"OAuth management requires interactive operator authentication"}', + '{"error":"OAuth management requires interactive user authentication"}', ); for (const path of [ diff --git a/test/server.test.ts b/test/server.test.ts index 11ae11ca..229eaed4 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1056,7 +1056,7 @@ describe("server /mcp end-to-end", () => { expect(payload.authorizationUrl).toContain("auth.example"); }); - it("gives a bearer-only deployment a safe handoff but keeps mutation Clerk-only", async () => { + it("gives a bearer-only deployment a safe handoff but keeps mutation interactive-only", async () => { const c = createTestConnecta({ connectors: [recoverableStaticConnector()], auth: bearerToken(TOKEN), @@ -1077,7 +1077,9 @@ describe("server /mcp end-to-end", () => { recovery: "operator_config", operatorUrl: `${BASE}/credentials`, }); - expect(recovery.instructions).toContain("Clerk-authenticated operator"); + expect(recovery.instructions).toContain( + "signed-in human with access to this connector", + ); const mutation = await c.fetch( new Request(`${BASE}/ui/credentials/static`, { @@ -1094,7 +1096,7 @@ describe("server /mcp end-to-end", () => { ); expect(mutation.status).toBe(403); expect(await mutation.json()).toEqual({ - error: "credential management requires interactive operator authentication", + error: "credential management requires interactive user authentication", }); }); diff --git a/vitest.config.ts b/vitest.config.ts index fb16ad17..168cad45 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,6 +30,7 @@ export const WORKERS_SUITES = [ "test/execute-ui.test.ts", "test/guarded-fetch.test.ts", "test/guest-api-contract.test.ts", + "test/identity-scope.test.ts", "test/linear-provider.test.ts", "test/meta-tools-call.test.ts", "test/meta-tools-search.test.ts",