diff --git a/AGENTS.md b/AGENTS.md index c684fef725..acebd5ebf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ This file is intentionally brief. Detailed instructions live in focused docs: - [docs/agents/oxlint-js-plugins.md](./docs/agents/oxlint-js-plugins.md) - [docs/agents/remix/index.md](./docs/agents/remix/index.md) - [docs/agents/cloudflare-agents-sdk.md](./docs/agents/cloudflare-agents-sdk.md) - - [docs/agents/mcp-apps-starter-guide.md](./docs/agents/mcp-apps-starter-guide.md) +- [docs/agents/mcp-apps-spec-notes.md](./docs/agents/mcp-apps-spec-notes.md) - MCP capabilities (search/execute graph, domains, registry): - [docs/agents/adding-capabilities.md](./docs/agents/adding-capabilities.md) - Project setup references: diff --git a/docs/agents/adding-capabilities.md b/docs/agents/adding-capabilities.md index e2732ef0d2..662df1e2b0 100644 --- a/docs/agents/adding-capabilities.md +++ b/docs/agents/adding-capabilities.md @@ -3,6 +3,11 @@ User-persisted codemode skills (`meta` domain) are documented in [`mcp-skills.md`](./mcp-skills.md). +Secret-bearing outbound requests are governed by +[`secret-host-approval.md`](./secret-host-approval.md). Read that doc before +adding any capability or workflow that saves secrets, uses placeholder-based +`fetch`, or discusses host approval. + Kody exposes a compact MCP surface (`search` and `execute`) and keeps the real capability graph behind that surface. To add a new capability, register it through a **domain** and the **builtin registry**—do not add a new public MCP @@ -188,6 +193,10 @@ Keep handlers focused on host-side work. The sandboxed model code should only orchestrate capability calls; it should not hold credentials or perform raw network access. +For secret-aware outbound requests, treat host approval as admin-only policy. Do +not add MCP-side or execute-time mutation paths for a secret's allowed hosts. +Only the authenticated account admin UI may widen that policy. + `CapabilityContext` provides: - `env`: access to Cloudflare bindings such as D1, KV, R2, AI, and Worker @@ -202,6 +211,13 @@ Use handlers for things like: - Cloudflare product APIs - containers or sandbox orchestration +If a capability surfaces secret metadata or secret-using network behavior, make +the description explicit about the approval model: + +- secret save/update does not authorize outbound use +- a blocked host must be approved through the account admin UI +- the agent should stop and surface the approval link instead of retrying + ## Testing Public MCP behavior should be verified through the compact tool surface: diff --git a/docs/agents/mcp-apps-spec-notes.md b/docs/agents/mcp-apps-spec-notes.md new file mode 100644 index 0000000000..b118e903de --- /dev/null +++ b/docs/agents/mcp-apps-spec-notes.md @@ -0,0 +1,27 @@ +# MCP Apps Spec Notes + +This repo currently uses one generic MCP Apps shell rather than a family of app +implementations. For MCP Apps behavior, prefer the upstream spec and API docs +over project-local conventions. + +## Read First + +- [MCP Apps API docs](https://apps.extensions.modelcontextprotocol.io/api/) +- [MCP Apps stable spec](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx) +- [MCP UI introduction](https://mcpui.dev/guide/introduction) +- [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) + +## Repo-Specific Notes + +- The repo exposes a single generic shell via `open_generated_ui`. +- Saved apps are reopened by `app_id`; inline renders are ephemeral. +- If an OAuth provider requires a callback URL, use a persisted hosted saved app + rather than an inline render. +- For secret-bearing requests and host approval policy, also read + [`secret-host-approval.md`](./secret-host-approval.md). + +## Relevant Code + +- `packages/worker/src/mcp/tools/open-generated-ui.ts` +- `packages/worker/client/mcp-apps/generated-ui-shell.ts` +- `packages/worker/client/routes/saved-ui.tsx` diff --git a/docs/agents/mcp-apps-starter-guide.md b/docs/agents/mcp-apps-starter-guide.md deleted file mode 100644 index 592aa33a24..0000000000 --- a/docs/agents/mcp-apps-starter-guide.md +++ /dev/null @@ -1,282 +0,0 @@ -# MCP Apps starter guide - -This guide explains how to build MCP Apps in this starter project in a reusable -way. The current implementation uses a single generic shell resource that can -render inline generated code or reopen saved UI artifacts by id. The shell is -intentionally thin: it just hosts an iframe, delivers render payloads, and -bridges host actions back to the MCP App host. - -Use this when replacing starter tools/resources with your own product-specific -UI and workflows. - -## Goals - -- Build MCP tools that can open interactive UI widgets in MCP App-compatible - hosts. -- Keep tool/resource metadata aligned with the MCP Apps specification. -- Keep implementation modular so starter examples are easy to replace. -- Ensure apps support host messaging and predictable validation. - -## Architecture in this repo - -Use this file map as the default structure: - -- `packages/worker/src/mcp/index.ts` - - MCP server + `init()` registration entrypoint. -- `packages/worker/src/mcp/register-tools.ts` - - Aggregates tool registration. -- `packages/worker/src/mcp/register-resources.ts` - - Aggregates resource registration. -- `packages/worker/src/mcp/tools/*.ts` - - One tool per file. -- `packages/worker/src/mcp/resources/*.ts` - - One resource registration module per file. -- `packages/worker/src/mcp/apps/*.ts` - - UI entry-point modules that return HTML/JS payloads for `ui://` resources. - -## Recommended implementation workflow - -### 1) Create an app entry point - -Create a dedicated module under `packages/worker/src/mcp/apps/`: - -- Export a stable `ui://` URI. -- Export a render function that returns the app HTML. -- Keep shell markup minimal and push visible UI into the generated app source. - -Keep file names lower-kebab-case and prefer one entry point per app. - -### 2) Register the app resource - -In `packages/worker/src/mcp/resources/.ts`: - -- Use `registerAppResource(...)` from `@modelcontextprotocol/ext-apps/server`. -- Return `text/html;profile=mcp-app`. -- Use `createUIResource(...)` from `@mcp-ui/server` when you need adapter - injection. -- Enable the `mcpApps` adapter when UI events should be translated into MCP Apps - host JSON-RPC. -- Set `_meta.ui.domain` on resource contents to the widget origin (required for - app submission). -- Add `_meta["openai/widgetDomain"]` as a compatibility alias for ChatGPT. - -### 3) Register the app-opening tool - -In `packages/worker/src/mcp/tools/.ts`: - -- Use `registerAppTool(...)` from `@modelcontextprotocol/ext-apps/server`. -- Set `_meta.ui.resourceUri` to the **same** `ui://` URI as the resource. -- Include annotations (`readOnlyHint`, `idempotentHint`, etc.). -- Provide `outputSchema` for machine-usable outputs where relevant. -- Prefer returning a compact render envelope in `structuredContent` so a single - shell can render different app payloads per invocation. -- Prefer self-contained HTML documents or fragments as the render source so the - generated app owns the visible document. - -### 4) Wire registration in server init - -- Add resource registration to `packages/worker/src/mcp/register-resources.ts`. -- Add tool registration to `packages/worker/src/mcp/register-tools.ts`. -- Ensure `packages/worker/src/mcp/index.ts` calls both in `init()`. - -### 5) Add or update MCP E2E coverage - -At minimum, cover: - -- `listTools` includes your new tool. -- `listResources` includes your `ui://` resource. -- `readResource` returns expected MIME type + payload markers. -- `readResource` metadata includes widget domain + CSP expectations. -- `callTool` returns expected content/structuredContent. - -## Host messaging patterns - -When a UI should communicate back to the host agent: - -- Prefer the standard MCP Apps bridge (`App` from - `@modelcontextprotocol/ext-apps`) and call host methods such as: - - `ui/message` (send a user-style message) - - `tools/call` (call server tools) - - `ui/open-link` (request external link open) -- Use app-only tools (`visibility: ["app"]`) for server-side follow-up work that - should stay out of the model tool list, such as loading saved app source by id - or polling for live widget state. -- Keep messages concise and deterministic where possible. -- For inline `rawHtml` widgets in this repo, prefer reusing the shared runtime - in `packages/worker/client/mcp-apps/widget-host-bridge.ts` (bundled into - `packages/worker/public/mcp-apps/generated-ui-shell.js`) instead of - duplicating bridge code. - -### Current repo shell contract - -The current `generated-ui-shell` exposes a tiny helper on `window.kodyWidget`: - -- `sendMessage(text)` -- `openLink(url)` -- `toggleFullscreen()` -- `executeCode(code)` -- automatically reports rendered widget size via `ui/notifications/size-changed` - -Keep the interface small and literal. Narrow, well-named helpers are easier for -agents to use reliably than a generic RPC surface. - -`executeCode(code)` is not local eval inside the widget. It posts back to the -host, and the host fulfills that request by calling the Kody MCP server's -`execute` tool with `{ code }`. Treat it as a narrow convenience wrapper around -that host-mediated tool call. - -You can also send simplified MCP-UI actions via `window.parent.postMessage(...)` -(`type: 'tool' | 'prompt' | 'notify' | 'link'`) when using the `mcpApps` -adapter. Those shorthand actions depend on adapter translation and may not be -available in every host runtime. - -### Fullscreen and display modes - -- Prefer `ui/request-display-mode` for fullscreen entry/exit instead of trying - to manipulate the iframe directly. -- Treat fullscreen as host-dependent. Always check `availableDisplayModes` from - render data / host context before offering the action. -- Keep fullscreen UI optional and degrade gracefully when unsupported. - -### Generic shell render contract - -The current repo uses one generic shell resource: - -- `ui://generated-ui-shell/entry-point.html` - -The public tool should accept exactly one of: - -- inline source (ephemeral render) -- saved `app_id` (server-resolved reopen) - -Recommended render envelope fields in tool `structuredContent`: - -- render mode (`inline_code` or `saved_app`) -- saved artifact id when present -- optional title / description for the current render session -- inline source only for ephemeral renders - -This keeps the shell reusable while avoiding source round-trips through the -model for saved artifacts. - -## HTML-First Contract - -Prefer generated app source that is already a complete HTML document, or at -least a self-contained HTML fragment. That keeps the generic shell simple and -avoids coupling the app contract to shell-owned containers like `#app`. - -In this repo, prefer body-ready fragments when possible so agents can rely on -the shell's built-in baseline styles. Preserve full-document HTML support for -cases where the generated app needs total control over head metadata, scripts, -or layout. - -If you keep a helper bridge, treat it as transport only: - -- send host messages -- request display modes -- run follow-up code through a narrow helper such as `executeCode` -- avoid shipping shell-owned visible controls - -## Theme and design-system guidance - -### Theme support - -For robust light/dark behavior: - -- Support browser fallback with `prefers-color-scheme`. -- If your generated app wants host-provided theme, pass it through your bridge - contract explicitly rather than relying on shell-owned UI chrome. - -### Design-system alignment - -Prefer app token names so widgets stay visually consistent with the host app: - -- `--color-*` -- `--spacing-*` -- `--radius-*` -- `--shadow-*` -- shared typography tokens (`--font-*`) - -The current shell also ships a lightweight semantic stylesheet so plain HTML -forms, buttons, tables, lists, and code blocks look reasonable without extra -CSS. Treat that baseline as a convenience layer, not a component framework. - -When app and widget are served from the same origin, prefer referencing the -canonical stylesheet directly (for example `/styles.css`) instead of copying -token values into widget CSS. If you do this in an MCP App resource, set -`_meta.ui.csp.resourceDomains` to allow that stylesheet origin. - -For generated HTML rendered through a sandboxed `srcdoc` iframe, avoid injecting -a cross-origin `` tag to make relative URLs work. Hosts like ChatGPT may -enforce `base-uri 'self'`, which blocks that pattern. Prefer rewriting relative -asset/navigation URLs to absolute app URLs before assigning the HTML. - -## Security and metadata checklist - -- Keep resources sandbox-friendly (no unnecessary external dependencies). -- If loading external assets/APIs, define explicit `_meta.ui.csp` domains. -- When serving widget JS/CSS from your app origin (for example `/mcp-apps/*` or - `/styles.css`), add `Access-Control-Allow-Origin` so sandboxed iframes with - opaque origins can fetch assets in ChatGPT/MCP Jam. -- If you use Workers static assets, configure `assets.run_worker_first` for - widget asset paths so those requests pass through your CORS logic. -- Always set `_meta.ui.domain` and `_meta["openai/widgetDomain"]` to your app's - dedicated widget origin. -- Request only required permissions in `_meta.ui.permissions`. -- Avoid embedding secrets or private tokens in UI payloads. - -## Quality checklist before merge - -- `npm run format` -- `npm run test:mcp` (auto-prepares `packages/worker/.env` from `.env.example` - when needed) -- `npm run validate` -- `npm run inspect` -- Confirm docs in `docs/agents` reflect any new workflow or constraints. - -## Inspector and MCP Jam verification - -For end-to-end verification beyond local MCP tests: - -1. Run `npm run inspect`. -2. Connect the local MCP server in the inspector / MCP Jam flow. -3. Call the generic app-opening tool with inline source and confirm the shell - renders the generated UI. -4. Save an app artifact, then reopen it by `app_id` and confirm the shell loads - it without resending the saved source through the model. -5. Verify host interactions that are supported by your widget: - - `ui/message` - - app-only `tools/call` - - `ui/open-link` - - `ui/request-display-mode` / fullscreen when the host exposes it - -Treat MCP Jam as the highest-signal browser verification path because it matches -the sandboxed MCP Apps host model more closely than a plain iframe. - -## Replacing starter examples safely - -When removing starter examples: - -1. Delete example modules under `packages/worker/src/mcp/apps`, - `packages/worker/src/mcp/tools`, and `packages/worker/src/mcp/resources`. -2. Replace entries in registration modules. -3. Update MCP E2E tests to your new tool/resource names and behavior. -4. Update this guide (or adjacent docs) if your project-specific conventions - differ from defaults. - -## References - -Core MCP Apps docs/spec: - -- MCP UI introduction: https://mcpui.dev/guide/introduction -- `@modelcontextprotocol/ext-apps` repository: - https://github.com/modelcontextprotocol/ext-apps -- MCP Apps API docs: https://apps.extensions.modelcontextprotocol.io/api/ -- MCP Apps stable spec (2026-01-26): - https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx - -Repo-specific implementation references: - -- MCP server patterns: `docs/mcp-server-patterns.md` -- Cloudflare Agents SDK notes: `docs/agents/cloudflare-agents-sdk.md` -- Worker MCP entrypoint wiring: `packages/worker/src/index.ts` diff --git a/docs/agents/secret-host-approval.md b/docs/agents/secret-host-approval.md new file mode 100644 index 0000000000..36412a8ea2 --- /dev/null +++ b/docs/agents/secret-host-approval.md @@ -0,0 +1,98 @@ +# Secret Host Approval Policy + +This document describes the required policy for outbound requests that use saved +secrets. + +## Rule + +Allowed outbound hosts for a secret are privileged policy, not normal secret +metadata. + +That policy must not be created, widened, or modified by: + +- MCP tools +- execute-time sandboxed code +- generated UI code +- capability handlers that serve agent-driven secret creation or update flows + +Allowed outbound hosts may only be changed through the authenticated account +admin UI approval flow. + +In this repo, that means the user must approve host access through the account +secrets experience, such as `/account/secrets` and the focused approval route at +`/account/secrets/approve`. + +## What agents should assume + +Agents should assume all newly created secrets start with an empty host +allowlist unless the user has already approved one or more hosts in the admin +UI. + +Saving or updating a secret value does not authorize sending that secret to any +host. + +If an outbound request uses a placeholder such as `{{secret:name}}` and the +target host is not already approved for that secret, the correct behavior is: + +1. Stop retrying. +2. Show the user the approval link from the error. +3. Ask the user whether they want to approve that host in the admin UI. +4. Retry only after the user approves it. + +## What agents must not do + +Do not design or document any MCP capability, generated UI helper, or client +library that allows agent-controlled writes to a secret's allowed hosts. + +Specifically, do not: + +- add `allowed_hosts` or equivalent fields to MCP-facing secret create/update + inputs +- imply that a generated UI can self-authorize a host just because it can save a + secret +- imply that execute-time code can widen egress permissions +- treat host approval as ordinary secret metadata editing + +If a workflow would be smoother by auto-approving a host, the fix should be +better guidance, helper APIs, or UX around the approval flow, not a new write +path that bypasses the admin UI. + +## Guidance for capability authors + +When writing capability descriptions or agent-facing docs: + +- say explicitly that secret save/update does not grant outbound use +- say explicitly that only the authenticated account admin UI can approve hosts +- tell agents to inspect secret metadata before making a secret-bearing request +- tell agents to surface the approval link and stop on deny + +This policy is especially important in: + +- secret create/update/list capabilities +- execute-time fetch documentation +- generated UI shell documentation +- OAuth and other hosted callback examples + +## Guidance for generated UI flows + +Generated UIs may: + +- collect secret values from the user +- save those values as secrets +- inspect secret metadata, including current allowed hosts +- present approval links returned from blocked requests + +Generated UIs may not: + +- set allowed hosts directly +- bypass the admin approval route +- silently retry secret-bearing requests after a deny + +For OAuth and similar flows, prefer this sequence: + +1. Save the client credentials as secrets. +2. Use a hosted saved app as the callback page when helpful. +3. Attempt the token exchange with secret placeholders. +4. If the exchange is blocked on host approval, send the user to the admin UI + approval page. +5. Retry after approval. diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index bc849c02d5..31fc428323 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -21,6 +21,13 @@ test('smoke test covers shell, auth redirect, and login', async ({ page }) => { await expect(page).toHaveURL(/\/account$/) await expect( - page.getByRole('heading', { name: `Welcome, ${primaryTestUser.email}` }), + page.getByRole('heading', { + name: `${primaryTestUser.email} account`, + }), + ).toBeVisible() + await expect( + page.getByRole('link', { + name: 'Manage secrets', + }), ).toBeVisible() }) diff --git a/package.json b/package.json index 0fea7b3ccc..e7905d9055 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "dev:mock-github": "node --env-file=packages/worker/.env ./wrangler-env.ts dev --local --config packages/mock-servers/github/wrangler.jsonc", "dev:mock-cursor": "node --env-file=packages/worker/.env ./wrangler-env.ts dev --local --config packages/mock-servers/cursor/wrangler.jsonc", "dev:mock-cloudflare": "node --env-file=packages/worker/.env ./wrangler-env.ts dev --local --config packages/mock-servers/cloudflare/wrangler.jsonc", - "deploy": "npm run build && node --env-file=packages/worker/.env ./wrangler-env.ts deploy --outdir .wrangler/sentry-bundle --upload-source-maps", + "deploy": "node --input-type=module -e \"if (['preview','test'].includes(process.env.CLOUDFLARE_ENV ?? '')) { await import('./tools/prepare-e2e-env.ts'); }\" && npm run build && node --env-file=packages/worker/.env ./wrangler-env.ts deploy --outdir .wrangler/sentry-bundle --upload-source-maps", "sentry:upload-sourcemaps": "node tools/sentry-upload-sourcemaps.ts", "build:mcp-apps": "esbuild packages/worker/client/mcp-apps/generated-ui-shell.ts --bundle --format=esm --target=es2022 --outdir=packages/worker/public/mcp-apps --platform=browser", "build:client:web": "esbuild packages/worker/client/entry.tsx --bundle --format=esm --target=es2022 --outdir=packages/worker/public --entry-names=client-entry --chunk-names=assets/[name] --asset-names=assets/[name] --jsx=automatic --jsx-import-source=remix/component", @@ -45,7 +45,7 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:install": "playwright install chromium --with-deps", - "test:mcp": "node --env-file=packages/worker/.env tools/prepare-e2e-env.ts && npm run build:mcp-apps && vitest run --project mcp-e2e", + "test:mcp": "node tools/prepare-e2e-env.ts && npm run build:mcp-apps && vitest run --project mcp-e2e", "nx:graph": "nx graph", "nx:show-projects": "nx show projects" }, diff --git a/packages/shared/src/account-secret-route.ts b/packages/shared/src/account-secret-route.ts new file mode 100644 index 0000000000..20c6e9da1f --- /dev/null +++ b/packages/shared/src/account-secret-route.ts @@ -0,0 +1,119 @@ +type AccountSecretRouteScope = 'app' | 'session' | 'user' +const accountSecretsBasePath = '/account/secrets' + +export type AccountSecretRouteIdInput = { + name: string + scope: AccountSecretRouteScope + appId?: string | null + sessionId?: string | null +} + +export type ParsedAccountSecretRouteId = { + name: string + scope: AccountSecretRouteScope + appId: string | null + sessionId: string | null +} + +export type ParsedAccountSecretRoutePath = ParsedAccountSecretRouteId & { + id: string +} + +export function buildAccountSecretId(input: AccountSecretRouteIdInput) { + const bindingId = + input.scope === 'app' + ? (input.appId ?? '') + : input.scope === 'session' + ? (input.sessionId ?? '') + : '' + return `${input.scope}::${encodeURIComponent(bindingId)}::${encodeURIComponent( + input.name, + )}` +} + +export function parseAccountSecretId( + secretId: string, +): ParsedAccountSecretRouteId | null { + const [scope, encodedBindingId, encodedName, ...rest] = secretId.split('::') + if (rest.length > 0) return null + if (scope !== 'app' && scope !== 'session' && scope !== 'user') return null + + try { + const name = decodeURIComponent(encodedName ?? '') + const bindingId = decodeURIComponent(encodedBindingId ?? '') + if (!name.trim()) return null + + return { + name, + scope, + appId: scope === 'app' ? bindingId || null : null, + sessionId: scope === 'session' ? bindingId || null : null, + } + } catch { + return null + } +} + +export function buildAccountSecretPath(input: AccountSecretRouteIdInput) { + const name = encodeURIComponent(input.name) + if (input.scope === 'user') { + return `${accountSecretsBasePath}/user/${name}` + } + if (input.scope === 'app') { + const appId = encodeURIComponent(input.appId ?? '') + return `${accountSecretsBasePath}/app/${appId}/${name}` + } + const sessionId = encodeURIComponent(input.sessionId ?? '') + return `${accountSecretsBasePath}/session/${sessionId}/${name}` +} + +export function parseAccountSecretPath( + pathname: string, +): ParsedAccountSecretRoutePath | null { + const segments = pathname.replace(/\/+$/, '').split('/') + if (segments.length === 0) return null + + const [empty, account, secrets, ...rest] = segments + if (empty !== '' || account !== 'account' || secrets !== 'secrets') { + return null + } + + try { + if (rest.length === 2 && rest[0] === 'user') { + const parsed = { + name: decodeURIComponent(rest[1] ?? ''), + scope: 'user' as const, + appId: null, + sessionId: null, + } + if (!parsed.name.trim()) return null + const id = buildAccountSecretId(parsed) + return { ...parsed, id } + } + if (rest.length === 3 && rest[0] === 'app') { + const parsed = { + name: decodeURIComponent(rest[2] ?? ''), + scope: 'app' as const, + appId: decodeURIComponent(rest[1] ?? '') || null, + sessionId: null, + } + if (!parsed.name.trim() || !parsed.appId) return null + const id = buildAccountSecretId(parsed) + return { ...parsed, id } + } + if (rest.length === 3 && rest[0] === 'session') { + const parsed = { + name: decodeURIComponent(rest[2] ?? ''), + scope: 'session' as const, + appId: null, + sessionId: decodeURIComponent(rest[1] ?? '') || null, + } + if (!parsed.name.trim() || !parsed.sessionId) return null + const id = buildAccountSecretId(parsed) + return { ...parsed, id } + } + return null + } catch { + return null + } +} diff --git a/packages/worker/client/app.tsx b/packages/worker/client/app.tsx index d478250277..3720ebba66 100644 --- a/packages/worker/client/app.tsx +++ b/packages/worker/client/app.tsx @@ -89,7 +89,9 @@ export function App(handle: Handle) { return () => { const isWideLayout = - currentPathname.startsWith('/chat') || currentPathname.startsWith('/ui/') + currentPathname.startsWith('/chat') || + currentPathname.startsWith('/ui/') || + currentPathname.startsWith('/account/secrets') const sessionEmail = session?.email ?? '' const isSessionReady = sessionStatus === 'ready' const isLoggedIn = isSessionReady && Boolean(sessionEmail) @@ -176,7 +178,7 @@ export function App(handle: Handle) { Chat - + {sessionEmail}
diff --git a/packages/worker/client/mcp-apps/generated-ui-shell.ts b/packages/worker/client/mcp-apps/generated-ui-shell.ts index 9035544bfb..87a95d6620 100644 --- a/packages/worker/client/mcp-apps/generated-ui-shell.ts +++ b/packages/worker/client/mcp-apps/generated-ui-shell.ts @@ -11,6 +11,7 @@ type SecretMetadata = { scope: SecretScope description: string app_id: string | null + allowed_hosts: Array created_at: string updated_at: string ttl_ms: number | null @@ -634,6 +635,8 @@ function initializeGeneratedUiShell() { coerceSecretScope(secret.scope) != null && typeof secret.description === 'string' && (secret.app_id == null || typeof secret.app_id === 'string') && + Array.isArray(secret.allowed_hosts) && + secret.allowed_hosts.every((host) => typeof host === 'string') && typeof secret.created_at === 'string' && typeof secret.updated_at === 'string' && (secret.ttl_ms == null || @@ -921,6 +924,212 @@ function nextRequestId() { requestCounter += 1; return 'generated-ui-' + requestCounter; } +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +function coerceSecretScope(value) { + return value === 'session' || value === 'app' || value === 'user' ? value : null; +} +function normalizeSecretNameList(values) { + return Array.from(new Set(values.filter((value) => typeof value === 'string' && value.length > 0))); +} +function extractSecretNamesFromValue(value, collected = []) { + if (typeof value === 'string') { + for (const match of value.matchAll(/\\{\\{secret:([a-zA-Z0-9._-]+)/g)) { + if (match[1]) collected.push(match[1]); + } + return collected; + } + if (Array.isArray(value)) { + for (const entry of value) { + extractSecretNamesFromValue(entry, collected); + } + return collected; + } + if (isRecord(value)) { + for (const entry of Object.values(value)) { + extractSecretNamesFromValue(entry, collected); + } + } + return collected; +} +function extractApprovalDetails(message, fallbackSecretNames = []) { + const text = typeof message === 'string' ? message : String(message ?? ''); + const secretNames = normalizeSecretNameList([ + ...Array.from(text.matchAll(/Secret "([^"]+)"/g)).map((match) => match[1]).filter(Boolean), + ...fallbackSecretNames, + ]); + const hostMatch = text.match(/host "([^"]+)"/); + let approvalUrl = null; + for (const part of text.split(/\\s+/)) { + if (part.startsWith('http://') || part.startsWith('https://')) { + approvalUrl = part.replace(/[),.;]+$/, ''); + break; + } + } + return { + message: text, + approvalUrl, + host: hostMatch ? hostMatch[1] : null, + secretNames, + }; +} +function resolveFormReference(formRef) { + if (typeof formRef === 'string') { + return document.querySelector(formRef); + } + return formRef && typeof formRef === 'object' ? formRef : null; +} +function formDataToObject(formData) { + const result = {}; + for (const name of new Set(formData.keys())) { + const all = formData.getAll(name); + result[name] = all.length > 1 ? all : (all[0] ?? null); + } + return result; +} +function pickLastFormValue(value) { + if (Array.isArray(value)) { + return value.length > 0 ? value[value.length - 1] : null; + } + return value ?? null; +} +function toStringArray(value) { + if (Array.isArray(value)) { + return value.map((entry) => (typeof entry === 'string' ? entry : String(entry))); + } + if (value == null) return []; + return [typeof value === 'string' ? value : String(value)]; +} +function setControlValues(form, name, values) { + const controls = Array.from(form.elements).filter((element) => { + return element && typeof element === 'object' && 'name' in element && element.name === name; + }); + if (controls.length === 0) return; + for (const control of controls) { + if (!control || typeof control !== 'object') continue; + if ('type' in control && (control.type === 'checkbox' || control.type === 'radio')) { + const controlValue = typeof control.value === 'string' && control.value.length > 0 ? control.value : 'on'; + control.checked = values.includes(controlValue) || (control.type === 'checkbox' && controlValue === 'on' && values.some((value) => value === 'true' || value === '1' || value === 'on')); + continue; + } + if ('multiple' in control && control.multiple && 'options' in control && control.options) { + for (const option of Array.from(control.options)) { + option.selected = values.includes(option.value); + } + continue; + } + if ('value' in control) { + control.value = values.length > 0 ? values[values.length - 1] : ''; + } + } +} +function getTopLocationUrl(inputUrl) { + if (inputUrl) { + return new URL(inputUrl, window.location.href); + } + try { + if (window.top && window.top.location && window.top.location.href) { + return new URL(window.top.location.href); + } + } catch {} + return new URL(window.location.href); +} +function normalizeFetchWithSecretsInput(input) { + if (!isRecord(input)) { + return { ok: false, error: 'fetchWithSecrets input must be an object.' }; + } + if (typeof input.url !== 'string' || input.url.length === 0) { + return { ok: false, error: 'fetchWithSecrets requires a url.' }; + } + const headers = {}; + if (isRecord(input.headers)) { + for (const [key, value] of Object.entries(input.headers)) { + if (typeof value === 'string') { + headers[key] = value; + } + } + } + let body = input.body; + if (body != null && typeof body !== 'string') { + body = JSON.stringify(body); + const hasContentType = Object.keys(headers).some((key) => key.toLowerCase() === 'content-type'); + if (!hasContentType) { + headers['Content-Type'] = 'application/json'; + } + } + return { + ok: true, + value: { + url: input.url, + method: typeof input.method === 'string' && input.method.length > 0 ? input.method.toUpperCase() : 'GET', + headers, + body: typeof body === 'string' ? body : undefined, + }, + }; +} +function buildFetchWithSecretsExecuteCode(input) { + return [ + 'async () => {', + ' const response = await fetch(' + JSON.stringify(input.url) + ', {', + ' method: ' + JSON.stringify(input.method) + ',', + ' headers: ' + JSON.stringify(input.headers) + ',', + input.body != null ? ' body: ' + JSON.stringify(input.body) + ',' : '', + ' });', + ' const headers = Object.fromEntries(response.headers.entries());', + ' const contentType = response.headers.get("content-type") || "";', + ' const text = await response.text();', + ' let data = null;', + ' if (/\\\\bjson\\\\b/i.test(contentType) && text) {', + ' try {', + ' data = JSON.parse(text);', + ' } catch {}', + ' }', + ' return {', + ' ok: response.ok,', + ' status: response.status,', + ' headers,', + ' data,', + ' text: text || null,', + ' };', + '}', + ].filter(Boolean).join('\\n'); +} +function normalizeFetchWithSecretsResult(result) { + if (!isRecord(result)) { + return { + ok: false, + kind: 'execution_error', + message: 'fetchWithSecrets returned an invalid result.', + }; + } + const headers = isRecord(result.headers) + ? Object.fromEntries( + Object.entries(result.headers).filter((entry) => typeof entry[1] === 'string'), + ) + : {}; + const status = typeof result.status === 'number' ? result.status : 0; + const text = + typeof result.text === 'string' || result.text == null ? result.text : null; + const data = 'data' in result ? result.data : null; + if (result.ok === true) { + return { + ok: true, + status, + headers, + data, + text, + }; + } + return { + ok: false, + kind: 'http_error', + status, + headers, + data, + text, + }; +} function waitForShellMessage(type, requestId) { return new Promise((resolve) => { function handleMessage(event) { @@ -1026,6 +1235,42 @@ window.kodyWidget = { ? response : { ok: false, error: 'Unable to save secret.' }; }, + async saveSecrets(input) { + if (!Array.isArray(input)) { + return { + ok: false, + results: [ + { + name: '', + ok: false, + error: 'Secret inputs must be an array.', + }, + ], + }; + } + const results = []; + for (const item of input) { + if (!item || typeof item !== 'object') { + results.push({ + name: '', + ok: false, + error: 'Each secret input must be an object.', + }); + continue; + } + const response = await this.saveSecret(item); + results.push({ + name: typeof item.name === 'string' ? item.name : '', + ok: response.ok === true, + ...(response.secret ? { secret: response.secret } : {}), + ...(response.ok === true ? {} : { error: response.error || 'Unable to save secret.' }), + }); + } + return { + ok: results.every((result) => result.ok === true), + results, + }; + }, async listSecrets(input) { const response = await requestSecretAction( 'list-secrets', @@ -1034,6 +1279,384 @@ window.kodyWidget = { ); return Array.isArray(response) ? response : []; }, + formToObject(form) { + const resolvedForm = resolveFormReference(form); + if (!(resolvedForm instanceof HTMLFormElement)) { + throw new Error('formToObject requires an HTMLFormElement or a selector that resolves to one.'); + } + return formDataToObject(new FormData(resolvedForm)); + }, + fillFromSearchParams(form, mapping) { + const resolvedForm = resolveFormReference(form); + if (!(resolvedForm instanceof HTMLFormElement)) { + throw new Error('fillFromSearchParams requires an HTMLFormElement or a selector that resolves to one.'); + } + const url = getTopLocationUrl(); + const fieldNames = new Set( + Array.from(resolvedForm.elements) + .map((element) => ('name' in element ? element.name : '')) + .filter(Boolean), + ); + for (const name of fieldNames) { + const paramName = + isRecord(mapping) && typeof mapping[name] === 'string' && mapping[name].length > 0 + ? mapping[name] + : name; + const values = url.searchParams.getAll(paramName); + if (values.length === 0) continue; + setControlValues(resolvedForm, name, values); + } + return this.formToObject(resolvedForm); + }, + persistForm(form, options) { + const resolvedForm = resolveFormReference(form); + if (!(resolvedForm instanceof HTMLFormElement)) { + throw new Error('persistForm requires an HTMLFormElement or a selector that resolves to one.'); + } + if (!isRecord(options) || typeof options.storageKey !== 'string' || options.storageKey.length === 0) { + throw new Error('persistForm requires a storageKey option.'); + } + const values = this.formToObject(resolvedForm); + const fieldNames = Array.isArray(options.fields) + ? options.fields.filter((field) => typeof field === 'string' && field.length > 0) + : Object.keys(values); + const persisted = {}; + for (const name of fieldNames) { + if (!(name in values)) continue; + const value = values[name]; + const normalized = Array.isArray(value) + ? value.filter((entry) => typeof entry === 'string').map((entry) => entry) + : typeof value === 'string' + ? value + : null; + if (normalized != null) { + persisted[name] = normalized; + } + } + localStorage.setItem(options.storageKey, JSON.stringify(persisted)); + return persisted; + }, + restoreForm(form, options) { + const resolvedForm = resolveFormReference(form); + if (!(resolvedForm instanceof HTMLFormElement)) { + throw new Error('restoreForm requires an HTMLFormElement or a selector that resolves to one.'); + } + if (!isRecord(options) || typeof options.storageKey !== 'string' || options.storageKey.length === 0) { + throw new Error('restoreForm requires a storageKey option.'); + } + const raw = localStorage.getItem(options.storageKey); + if (!raw) return null; + let parsed = null; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!isRecord(parsed)) return null; + for (const [name, value] of Object.entries(parsed)) { + setControlValues(resolvedForm, name, toStringArray(value)); + } + return this.formToObject(resolvedForm); + }, + createOAuthState(key) { + if (typeof key !== 'string' || key.length === 0) { + throw new Error('createOAuthState requires a storage key.'); + } + const state = + globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); + sessionStorage.setItem(key, state); + return state; + }, + getOAuthState(key) { + if (typeof key !== 'string' || key.length === 0) return null; + return sessionStorage.getItem(key); + }, + clearOAuthState(key) { + if (typeof key !== 'string' || key.length === 0) return; + sessionStorage.removeItem(key); + }, + validateOAuthCallbackState(input) { + if (!isRecord(input) || typeof input.key !== 'string' || input.key.length === 0) { + throw new Error('validateOAuthCallbackState requires a key.'); + } + const expectedState = sessionStorage.getItem(input.key); + const returnedState = + typeof input.returnedState === 'string' && input.returnedState.length > 0 + ? input.returnedState + : null; + return { + valid: + typeof expectedState === 'string' && + expectedState.length > 0 && + returnedState != null && + expectedState === returnedState, + expectedState, + returnedState, + }; + }, + readOAuthCallback(input) { + const url = getTopLocationUrl(isRecord(input) && typeof input.url === 'string' ? input.url : undefined); + const error = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + if (error) { + return { + kind: 'error', + error, + errorDescription, + callbackUrl: url.toString(), + }; + } + const code = url.searchParams.get('code'); + if (!code) { + return { kind: 'none' }; + } + const state = url.searchParams.get('state'); + const expectedState = + isRecord(input) && typeof input.expectedStateKey === 'string' + ? sessionStorage.getItem(input.expectedStateKey) + : null; + return { + kind: 'success', + code, + state, + callbackUrl: url.toString(), + expectedState, + stateMatches: + expectedState != null && state != null ? expectedState === state : null, + }; + }, + async fetchWithSecrets(input) { + const normalized = normalizeFetchWithSecretsInput(input); + if (!normalized.ok) { + return { + ok: false, + kind: 'execution_error', + message: normalized.error, + }; + } + const fallbackSecretNames = normalizeSecretNameList( + extractSecretNamesFromValue([ + normalized.value.url, + Object.values(normalized.value.headers), + normalized.value.body, + ]), + ); + try { + const result = await this.executeCode( + buildFetchWithSecretsExecuteCode(normalized.value), + ); + return normalizeFetchWithSecretsResult(result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('not allowed for host')) { + const approval = extractApprovalDetails(message, fallbackSecretNames); + return { + ok: false, + kind: 'host_approval_required', + approvalUrl: approval.approvalUrl, + message: approval.message, + host: approval.host, + secretNames: approval.secretNames, + }; + } + return { + ok: false, + kind: 'execution_error', + message, + }; + } + }, + async exchangeOAuthCode(input) { + if (!isRecord(input)) { + return { + ok: false, + kind: 'execution_error', + message: 'exchangeOAuthCode input must be an object.', + }; + } + if ( + typeof input.tokenUrl !== 'string' || + typeof input.code !== 'string' || + typeof input.redirectUri !== 'string' || + typeof input.clientIdSecretName !== 'string' || + typeof input.clientSecretSecretName !== 'string' || + input.tokenUrl.length === 0 || + input.code.length === 0 || + input.redirectUri.length === 0 || + input.clientIdSecretName.length === 0 || + input.clientSecretSecretName.length === 0 + ) { + return { + ok: false, + kind: 'execution_error', + message: 'exchangeOAuthCode requires tokenUrl, code, redirectUri, clientIdSecretName, and clientSecretSecretName.', + }; + } + const scope = coerceSecretScope(input.scope); + const scopeSuffix = scope ? '|scope=' + scope : ''; + const params = new URLSearchParams(); + params.set('client_id', '{{secret:' + input.clientIdSecretName + scopeSuffix + '}}'); + params.set('client_secret', '{{secret:' + input.clientSecretSecretName + scopeSuffix + '}}'); + params.set('code', input.code); + params.set('redirect_uri', input.redirectUri); + if (isRecord(input.extraParams)) { + for (const [key, value] of Object.entries(input.extraParams)) { + if (value == null) continue; + params.set(key, typeof value === 'string' ? value : String(value)); + } + } + return await this.fetchWithSecrets({ + url: input.tokenUrl, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + }, + async saveOAuthTokens(input) { + if (!isRecord(input) || !isRecord(input.payload)) { + return { + ok: false, + accessTokenSaved: false, + refreshTokenSaved: false, + error: 'saveOAuthTokens requires a payload object.', + results: [], + }; + } + if (typeof input.accessTokenSecretName !== 'string' || input.accessTokenSecretName.length === 0) { + return { + ok: false, + accessTokenSaved: false, + refreshTokenSaved: false, + error: 'saveOAuthTokens requires an accessTokenSecretName.', + results: [], + }; + } + const accessToken = typeof input.payload.access_token === 'string' ? input.payload.access_token : ''; + const refreshToken = typeof input.payload.refresh_token === 'string' ? input.payload.refresh_token : ''; + if (!accessToken) { + return { + ok: false, + accessTokenSaved: false, + refreshTokenSaved: false, + error: 'OAuth payload did not include an access_token.', + results: [], + }; + } + const secrets = [ + { + name: input.accessTokenSecretName, + value: accessToken, + description: + typeof input.accessTokenDescription === 'string' + ? input.accessTokenDescription + : 'OAuth access token', + scope: coerceSecretScope(input.scope) ?? undefined, + }, + ]; + if (refreshToken && typeof input.refreshTokenSecretName === 'string' && input.refreshTokenSecretName.length > 0) { + secrets.push({ + name: input.refreshTokenSecretName, + value: refreshToken, + description: + typeof input.refreshTokenDescription === 'string' + ? input.refreshTokenDescription + : 'OAuth refresh token', + scope: coerceSecretScope(input.scope) ?? undefined, + }); + } + const saved = await this.saveSecrets(secrets); + return { + ok: saved.ok, + accessTokenSaved: saved.results.some((result) => result.name === input.accessTokenSecretName && result.ok === true), + refreshTokenSaved: + typeof input.refreshTokenSecretName === 'string' && input.refreshTokenSecretName.length > 0 + ? saved.results.some((result) => result.name === input.refreshTokenSecretName && result.ok === true) + : false, + error: saved.ok ? undefined : (saved.results.find((result) => result.ok !== true)?.error || 'Unable to save OAuth tokens.'), + results: saved.results, + }; + }, + buildSecretForm(input) { + if (!isRecord(input) || !Array.isArray(input.fields)) { + throw new Error('buildSecretForm requires a form config object with fields.'); + } + const form = resolveFormReference(input.form); + if (!(form instanceof HTMLFormElement)) { + throw new Error('buildSecretForm requires an HTMLFormElement or a selector that resolves to one.'); + } + const controller = { + form, + save: async () => { + const values = window.kodyWidget.formToObject(form); + const secrets = input.fields.map((field) => { + if (!field || typeof field !== 'object') { + throw new Error('Each secret field config must be an object.'); + } + if (typeof field.inputName !== 'string' || field.inputName.length === 0) { + throw new Error('Each secret field config requires inputName.'); + } + if (typeof field.secretName !== 'string' || field.secretName.length === 0) { + throw new Error('Each secret field config requires secretName.'); + } + const rawValue = pickLastFormValue(values[field.inputName]); + if (typeof rawValue !== 'string' || rawValue.length === 0) { + throw new Error('Form field "' + field.inputName + '" is required.'); + } + return { + name: field.secretName, + value: rawValue, + description: + typeof field.description === 'string' ? field.description : '', + scope: coerceSecretScope(field.scope) ?? undefined, + }; + }); + const result = await window.kodyWidget.saveSecrets(secrets); + if (result.ok) { + if (typeof input.onSuccess === 'function') { + await input.onSuccess(result, values); + } + } else if (typeof input.onError === 'function') { + await input.onError(result, values); + } + return result; + }, + destroy: () => { + form.removeEventListener('submit', handleSubmit); + }, + }; + async function handleSubmit(event) { + event.preventDefault(); + try { + await controller.save(); + } catch (error) { + if (typeof input.onError === 'function') { + await input.onError( + { + ok: false, + results: [ + { + name: '', + ok: false, + error: + error instanceof Error ? error.message : String(error), + }, + ], + }, + window.kodyWidget.formToObject(form), + ); + return; + } + throw error; + } + } + form.addEventListener('submit', handleSubmit); + return controller; + }, async deleteSecret(input) { if (!input || typeof input !== 'object') { return { ok: false, error: 'Secret input must be an object.' }; diff --git a/packages/worker/client/routes/account-approval-shared.ts b/packages/worker/client/routes/account-approval-shared.ts new file mode 100644 index 0000000000..cafcd492a0 --- /dev/null +++ b/packages/worker/client/routes/account-approval-shared.ts @@ -0,0 +1,49 @@ +export type AccountStatus = 'loading' | 'ready' | 'error' +export type ApprovalAction = 'approve' | 'reject' +export type ApprovalScope = 'session' | 'app' | 'user' + +export type ApprovalView = { + token: string + name: string + scope: ApprovalScope + requestedHost: string + currentAllowedHosts: Array +} + +export const accountSecretsApiPath = '/account/secrets.json' + +export function getScopeLabel(scope: ApprovalScope) { + if (scope === 'app') return 'App' + if (scope === 'session') return 'Session' + return 'User' +} + +export async function readJson(response: Response) { + return (await response.json().catch(() => null)) as T | null +} + +export async function submitApprovalRequest< + T extends { ok?: boolean; error?: string }, +>(action: ApprovalAction, requestToken: string) { + const response = await fetch(accountSecretsApiPath, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + action, + requestToken, + }), + }) + if (response.status === 401) { + window.location.assign('/login') + return null + } + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to process approval.') + } + return payload +} diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx new file mode 100644 index 0000000000..e14b4469a8 --- /dev/null +++ b/packages/worker/client/routes/account-secrets.tsx @@ -0,0 +1,1425 @@ +import { type Handle } from 'remix/component' +import { + buildAccountSecretPath, + parseAccountSecretId, + parseAccountSecretPath, +} from '@kody-internal/shared/account-secret-route.ts' +import { navigate, routerEvents } from '#client/client-router.tsx' +import { createDoubleCheck } from '#client/double-check.ts' +import { + type AccountStatus, + type ApprovalAction, + type ApprovalView, + accountSecretsApiPath, + getScopeLabel, + readJson, + submitApprovalRequest, +} from '#client/routes/account-approval-shared.ts' +import { TypeaheadCombobox } from '#client/typeahead-combobox.tsx' +import { + colors, + mq, + radius, + shadows, + spacing, + transitions, + typography, +} from '#client/styles/tokens.ts' + +type SecretScope = 'app' | 'user' + +type SavedAppOption = { + id: string + title: string + updatedAt: string +} + +type SecretListItem = { + id: string + name: string + scope: SecretScope + description: string + appId: string | null + appTitle: string | null + allowedHosts: Array + createdAt: string + updatedAt: string + ttlMs: number | null +} + +type SecretDetail = SecretListItem & { + value: string +} + +type AccountSecretsPayload = { + ok: true + email: string + apps: Array + secrets: Array + selectedSecret: SecretDetail | null + approval: ApprovalView | null +} + +type EditorState = { + currentId: string | null + name: string + scope: SecretScope + appId: string + description: string + value: string + allowedHosts: Array +} + +type SelectionState = { + selectedSecretId: string | null + isCreating: boolean +} + +type SecretFilterScope = 'all' | 'user' | 'app' + +type SecretFilterState = { + search: string + scope: SecretFilterScope + appId: string +} + +const secretsBasePath = '/account/secrets' + +function formatRelativeTtl(ttlMs: number | null) { + if (ttlMs == null) return 'No expiry' + const totalMinutes = Math.max(1, Math.round(ttlMs / 60_000)) + if (totalMinutes < 60) return `Expires in ${totalMinutes} min` + const totalHours = Math.round(totalMinutes / 60) + if (totalHours < 48) return `Expires in ${totalHours} hr` + const totalDays = Math.round(totalHours / 24) + return `Expires in ${totalDays} day${totalDays === 1 ? '' : 's'}` +} + +function formatTimestamp(value: string) { + return new Date(value).toLocaleString() +} + +function createEmptyEditorState(apps: Array): EditorState { + return { + currentId: null, + name: '', + scope: 'user', + appId: apps[0]?.id ?? '', + description: '', + value: '', + allowedHosts: [''], + } +} + +function createEditorStateFromSecret(secret: SecretDetail): EditorState { + return { + currentId: secret.id, + name: secret.name, + scope: secret.scope, + appId: secret.appId ?? '', + description: secret.description, + value: secret.value, + allowedHosts: secret.allowedHosts.length > 0 ? secret.allowedHosts : [''], + } +} + +function getSelectionState(href: string): SelectionState { + const url = new URL(href, 'http://localhost') + if (url.pathname === `${secretsBasePath}/new`) { + return { + selectedSecretId: null, + isCreating: true, + } + } + if (url.pathname === `${secretsBasePath}/approve`) { + return { + selectedSecretId: null, + isCreating: false, + } + } + const parsedPath = parseAccountSecretPath(url.pathname) + if (parsedPath) { + return { + selectedSecretId: parsedPath.id, + isCreating: false, + } + } + if (url.pathname.startsWith(`${secretsBasePath}/`)) { + const legacySecretId = url.pathname.slice(`${secretsBasePath}/`.length) + const parsedLegacyId = parseAccountSecretId(legacySecretId) + return { + selectedSecretId: parsedLegacyId + ? legacySecretId + : url.pathname.slice(`${secretsBasePath}/`.length), + isCreating: false, + } + } + return { + selectedSecretId: null, + isCreating: false, + } +} + +function getCurrentHref() { + return typeof window === 'undefined' ? secretsBasePath : window.location.href +} + +function getCurrentSearch() { + return typeof window === 'undefined' ? '' : window.location.search +} + +function buildSecretsHref(pathname: string, search = getCurrentSearch()) { + return `${pathname}${search}` +} + +function buildSecretHref(secret: { + name: string + scope: SecretScope + appId: string | null +}) { + return buildSecretsHref( + buildAccountSecretPath({ + name: secret.name, + scope: secret.scope, + appId: secret.appId, + }), + ) +} + +function buildNewSecretHref() { + return buildSecretsHref(`${secretsBasePath}/new`) +} + +function buildBaseSecretsHref() { + return buildSecretsHref(secretsBasePath) +} + +function replaceSecretsLocation(to: string) { + if (typeof window === 'undefined') return + const destination = new URL(to, window.location.href) + const nextPath = `${destination.pathname}${destination.search}${destination.hash}` + const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}` + if (nextPath === currentPath) return + window.history.replaceState({}, '', nextPath) + routerEvents.dispatchEvent(new Event('navigate')) +} + +function getDataRefreshKey(href: string) { + const url = new URL(href, 'http://localhost') + const request = url.searchParams.get('request') ?? '' + const requestedHost = url.searchParams.get('allowed-host') ?? '' + return `${url.pathname}?request=${request}&allowed-host=${requestedHost}` +} + +function readFilterState( + href: string, + apps: Array, +): SecretFilterState { + const url = new URL(href, 'http://localhost') + const search = url.searchParams.get('q')?.trim() ?? '' + const rawScope = url.searchParams.get('scope') + const scope = + rawScope === 'user' || rawScope === 'app' ? rawScope : ('all' as const) + const rawAppId = url.searchParams.get('app')?.trim() ?? '' + const appId = + scope === 'user' + ? '' + : apps.some((app) => app.id === rawAppId) + ? rawAppId + : '' + return { + search, + scope, + appId, + } +} + +function filterSecrets( + secrets: Array, + filters: SecretFilterState, +) { + const search = filters.search.trim().toLowerCase() + return secrets.filter((secret) => { + if (filters.scope !== 'all' && secret.scope !== filters.scope) return false + if ( + filters.scope !== 'user' && + filters.appId && + secret.appId !== filters.appId + ) + return false + if (!search) return true + + const haystack = [ + secret.name, + secret.description, + secret.appTitle ?? '', + secret.scope, + ...secret.allowedHosts, + ] + .join(' ') + .toLowerCase() + return haystack.includes(search) + }) +} + +function buildAppOptionDescription(updatedAt: string) { + return `Updated ${new Date(updatedAt).toLocaleDateString()}` +} + +export function AccountSecretsRoute(handle: Handle) { + let status: AccountStatus = 'loading' + let email = '' + let apps: Array = [] + let secrets: Array = [] + let selectedSecret: SecretDetail | null = null + let approval: ApprovalView | null = null + let editorState = createEmptyEditorState([]) + let message: string | null = null + let submittingApprovalAction: ApprovalAction | null = null + let saveState: 'idle' | 'saving' | 'deleting' = 'idle' + let lastLoadedDataKey = '' + let showSecretValue = false + const deleteSecretCheck = createDoubleCheck(handle) + const filterAppCombobox = TypeaheadCombobox(handle) + const editorAppCombobox = TypeaheadCombobox(handle) + + function buildHrefWithUpdatedFilters( + nextFilters: Partial, + options?: { pathname?: string }, + ) { + const currentUrl = new URL(getCurrentHref(), 'http://localhost') + const filters = { + ...readFilterState(currentUrl.toString(), apps), + ...nextFilters, + } + const nextUrl = new URL(currentUrl.toString()) + if (options?.pathname) { + nextUrl.pathname = options.pathname + } + if (filters.search) nextUrl.searchParams.set('q', filters.search) + else nextUrl.searchParams.delete('q') + if (filters.scope === 'all') nextUrl.searchParams.delete('scope') + else nextUrl.searchParams.set('scope', filters.scope) + if (filters.appId) nextUrl.searchParams.set('app', filters.appId) + else nextUrl.searchParams.delete('app') + return `${nextUrl.pathname}${nextUrl.search}` + } + + function syncEditorState(selection: SelectionState) { + deleteSecretCheck.reset() + showSecretValue = false + if (selection.isCreating) { + editorState = createEmptyEditorState(apps) + return + } + if (selectedSecret) { + editorState = createEditorStateFromSecret(selectedSecret) + return + } + editorState = createEmptyEditorState(apps) + } + + function applyPayload( + payload: AccountSecretsPayload, + selection: SelectionState, + nextMessage: string | null, + ) { + email = payload.email + apps = payload.apps + secrets = payload.secrets + selectedSecret = payload.selectedSecret + approval = payload.approval + syncEditorState(selection) + message = + nextMessage ?? + (selection.selectedSecretId && + !payload.selectedSecret && + !payload.approval + ? 'Secret not found.' + : null) + status = 'ready' + submittingApprovalAction = null + saveState = 'idle' + } + + async function loadAccountSecrets(signal: AbortSignal) { + try { + const href = getCurrentHref() + const selection = getSelectionState(href) + lastLoadedDataKey = getDataRefreshKey(href) + const requestUrl = new URL(accountSecretsApiPath, href) + requestUrl.search = new URL(href).search + if (selection.selectedSecretId) { + requestUrl.searchParams.set('selected', selection.selectedSecretId) + } else { + requestUrl.searchParams.delete('selected') + } + + const response = await fetch(requestUrl.toString(), { + headers: { Accept: 'application/json' }, + credentials: 'include', + signal, + }) + if (signal.aborted) return + if (response.status === 401) { + window.location.assign('/login') + return + } + + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load your secrets.') + } + + applyPayload(payload, selection, null) + handle.update() + } catch (error) { + if (signal.aborted) return + status = 'error' + message = + error instanceof Error ? error.message : 'Unable to load your secrets.' + handle.update() + } + } + + async function submitApproval(action: ApprovalAction) { + if (!approval || submittingApprovalAction != null) return + submittingApprovalAction = action + message = null + handle.update() + + try { + const payload = await submitApprovalRequest< + AccountSecretsPayload & { error?: string; ok?: boolean } + >(action, approval.token) + if (!payload) return + + const selection = getSelectionState(getCurrentHref()) + applyPayload( + payload, + selection, + action === 'approve' + ? 'Approved requested host.' + : 'Rejected host approval request.', + ) + handle.update() + + if (typeof window !== 'undefined' && window.location.search) { + const nextHref = selectedSecret + ? buildHrefWithUpdatedFilters( + {}, + { + pathname: buildAccountSecretPath({ + name: selectedSecret.name, + scope: selectedSecret.scope, + appId: selectedSecret.appId, + }), + }, + ) + : buildHrefWithUpdatedFilters({}, { pathname: secretsBasePath }) + const nextUrl = new URL(nextHref, window.location.href) + nextUrl.searchParams.delete('request') + nextUrl.searchParams.delete('allowed-host') + navigate(`${nextUrl.pathname}${nextUrl.search}`) + lastLoadedDataKey = getDataRefreshKey(nextUrl.toString()) + } + } catch (error) { + submittingApprovalAction = null + message = + error instanceof Error ? error.message : 'Unable to process approval.' + handle.update() + } + } + + async function saveSecretChanges(event: SubmitEvent) { + event.preventDefault() + if (saveState !== 'idle') return + + saveState = 'saving' + message = null + handle.update() + + try { + const response = await fetch(accountSecretsApiPath, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + action: 'save', + currentId: editorState.currentId, + name: editorState.name, + scope: editorState.scope, + appId: editorState.scope === 'app' ? editorState.appId : null, + description: editorState.description, + value: editorState.value, + allowedHosts: editorState.allowedHosts, + }), + }) + if (response.status === 401) { + window.location.assign('/login') + return + } + + const payload = await readJson< + AccountSecretsPayload & { error?: string; ok?: boolean } + >(response) + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to save secret.') + } + + const nextSelection: SelectionState = { + selectedSecretId: payload.selectedSecret?.id ?? null, + isCreating: false, + } + applyPayload( + payload, + nextSelection, + editorState.currentId ? 'Saved secret.' : 'Created secret.', + ) + handle.update() + + if (payload.selectedSecret) { + navigate(buildSecretHref(payload.selectedSecret)) + } + } catch (error) { + saveState = 'idle' + message = + error instanceof Error ? error.message : 'Unable to save secret.' + handle.update() + } + } + + async function deleteSelectedSecret() { + if (!editorState.currentId || saveState !== 'idle') return + + saveState = 'deleting' + message = null + handle.update() + + try { + const response = await fetch(accountSecretsApiPath, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + action: 'delete', + currentId: editorState.currentId, + }), + }) + if (response.status === 401) { + window.location.assign('/login') + return + } + + const payload = await readJson< + AccountSecretsPayload & { error?: string; ok?: boolean } + >(response) + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to delete secret.') + } + + applyPayload( + payload, + { selectedSecretId: null, isCreating: false }, + 'Deleted secret.', + ) + deleteSecretCheck.reset() + handle.update() + navigate(buildBaseSecretsHref()) + } catch (error) { + saveState = 'idle' + message = + error instanceof Error ? error.message : 'Unable to delete secret.' + handle.update() + } + } + + function updateAllowedHost(index: number, value: string) { + editorState = { + ...editorState, + allowedHosts: editorState.allowedHosts.map((host, hostIndex) => + hostIndex === index ? value : host, + ), + } + handle.update() + } + + function addAllowedHost() { + editorState = { + ...editorState, + allowedHosts: [...editorState.allowedHosts, ''], + } + handle.update() + } + + function removeAllowedHost(index: number) { + const nextHosts = editorState.allowedHosts.filter( + (_host, hostIndex) => hostIndex !== index, + ) + editorState = { + ...editorState, + allowedHosts: nextHosts.length > 0 ? nextHosts : [''], + } + handle.update() + } + + return () => { + const currentHref = getCurrentHref() + const selection = getSelectionState(currentHref) + const filters = readFilterState(currentHref, apps) + const filteredSecrets = filterSecrets(secrets, filters) + const appOptions = apps.map((app) => ({ + id: app.id, + label: app.title, + description: buildAppOptionDescription(app.updatedAt), + })) + const filterAppOptions = [ + { + id: '', + label: 'All apps', + description: 'Show secrets across every app', + }, + ...appOptions, + ] + const isRefreshingForLocationChange = + status !== 'loading' && + getDataRefreshKey(currentHref) !== lastLoadedDataKey + if (status === 'loading' || isRefreshingForLocationChange) { + handle.queueTask(loadAccountSecrets) + } + + const activeSecretId = + selection.selectedSecretId ?? selectedSecret?.id ?? null + const isMutating = saveState !== 'idle' || submittingApprovalAction != null + const canCreateAppSecrets = apps.length > 0 + const showEditor = selection.isCreating || selectedSecret != null + + return ( +
+
+
+

+ {email ? `${email} secrets` : 'Secrets'} +

+

+ Create, update, and delete the secrets available to your account + and saved apps. +

+
+ +
+ + {approval && !isRefreshingForLocationChange ? ( +
+
+

+ Approve host access +

+

+ Allow {approval.requestedHost} to receive secret{' '} + {approval.name} from the{' '} + {getScopeLabel(approval.scope)} scope. +

+

+ Current allowed hosts:{' '} + {approval.currentAllowedHosts.length > 0 + ? approval.currentAllowedHosts.join(', ') + : 'none'} +

+
+
+ + +
+
+ ) : null} + + {status === 'loading' ? ( +

Loading secrets…

+ ) : null} + {message ? ( +

+ {message} +

+ ) : null} + +
+ + +
+ {showEditor ? ( + +
+

+ {selection.isCreating ? 'New secret' : selectedSecret?.name} +

+

+ {selection.isCreating + ? 'Create a new user or app secret.' + : 'Update the secret value and metadata for this entry.'} +

+
+ +
+ + + +
+ + {editorState.scope === 'app' + ? editorAppCombobox({ + id: 'secret-editor-app', + label: 'App', + placeholder: 'Choose an app', + value: editorState.appId, + options: appOptions, + onChange: (appId) => { + editorState = { + ...editorState, + appId, + } + handle.update() + }, + inputCss, + listCss: comboboxListCss, + optionCss: comboboxOptionCss, + }) + : null} + +