From 1bce0018abd5a43ceb7dfaef6e78c83ff271a708 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 16:12:17 +0000 Subject: [PATCH 01/13] Add connect secret page Co-authored-by: Kent C. Dodds --- .../worker/src/app/handlers/connect-secret.ts | 768 ++++++++++++++++++ packages/worker/src/app/router.ts | 9 + packages/worker/src/app/routes.ts | 2 + 3 files changed, 779 insertions(+) create mode 100644 packages/worker/src/app/handlers/connect-secret.ts diff --git a/packages/worker/src/app/handlers/connect-secret.ts b/packages/worker/src/app/handlers/connect-secret.ts new file mode 100644 index 0000000000..54b1671002 --- /dev/null +++ b/packages/worker/src/app/handlers/connect-secret.ts @@ -0,0 +1,768 @@ +import { type BuildAction } from 'remix/fetch-router' +import { + generatedUiRuntimeScriptPath, + generatedUiRuntimeStylesheetPath, + resolveGeneratedUiAssetUrl, +} from '@kody-internal/shared/generated-ui-asset-paths.ts' +import { renderGeneratedUiDocument } from '@kody-internal/shared/generated-ui-documents.ts' +import { + buildGeneratedUiRuntimeImportMap, + injectGeneratedUiBootstrapScript, + type GeneratedUiRuntimeBootstrap, +} from '#client/mcp-apps/generated-ui-runtime-contract.ts' +import { readAuthenticatedAppUser } from '#app/authenticated-user.ts' +import { redirectToLogin } from '#app/auth-redirect.ts' +import { getAppBaseUrl } from '#app/app-base-url.ts' +import { + createGeneratedUiAppSession, + verifyGeneratedUiAppSession, +} from '#mcp/generated-ui-app-session.ts' +import { + listSecrets, + resolveSecret, +} from '#mcp/secrets/service.ts' +import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts' +import { secretScopeValues, type SecretScope } from '#mcp/secrets/types.ts' +import { saveValue } from '#mcp/values/service.ts' +import { type routes } from '#app/routes.ts' + +export function createConnectSecretHandler(env: Env) { + return { + middleware: [], + async action({ request }) { + const user = await readAuthenticatedAppUser(request, env) + if (!user) { + return redirectToLogin(request) + } + + const url = new URL(request.url) + const scope = readSecretScope(url) + const connector = readConnectorParam(url) + const name = readNameParam(url) + const appId = + scope === 'app' + ? buildConnectSecretAppId({ connector, name }) + : null + const baseUrl = getAppBaseUrl({ env, requestUrl: request.url }) + const appSession = await createGeneratedUiAppSession({ + env, + baseUrl, + user: user.mcpUser, + appId, + homeConnectorId: null, + }) + const html = renderGeneratedUiDocument({ + code: buildConnectSecretClientScript(), + runtime: 'javascript', + headInjection: buildHeadInjection({ + appSession, + appBaseUrl: baseUrl, + }), + baseHref: baseUrl, + }) + return new Response(html, { + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/html; charset=utf-8', + }, + }) + }, + } satisfies BuildAction< + typeof routes.connectSecret.method, + typeof routes.connectSecret.pattern + > +} + +export function createConnectSecretApiHandler(env: Env) { + return { + middleware: [], + async action({ request }) { + if (request.method !== 'POST') { + return jsonResponse({ ok: false, error: 'Method not allowed.' }, 405) + } + const user = await readAuthenticatedAppUser(request, env) + if (!user) { + return jsonResponse({ ok: false, error: 'Unauthorized.' }, 401) + } + const body = await request.json().catch(() => null) + if (!body || typeof body !== 'object') { + return jsonResponse({ ok: false, error: 'Invalid request body.' }, 400) + } + const name = readString(body, 'name') + const scope = readScope(body) + const sessionToken = readString(body, 'sessionToken') + if (!name) { + return jsonResponse({ ok: false, error: 'Secret name is required.' }, 400) + } + if (!scope) { + return jsonResponse({ ok: false, error: 'Secret scope is required.' }, 400) + } + if (!sessionToken) { + return jsonResponse( + { ok: false, error: 'Session token is required.' }, + 400, + ) + } + const connector = readOptionalString(body, 'connector') + const requestedAllowedHosts = + readOptionalStringArray(body, 'allowedHosts') ?? [] + let session + try { + session = await verifyGeneratedUiAppSession(env, sessionToken) + } catch (error) { + return jsonResponse( + { + ok: false, + error: + error instanceof Error + ? error.message + : 'Invalid session token.', + }, + 401, + ) + } + if (session.user.userId !== user.mcpUser.userId) { + return jsonResponse({ ok: false, error: 'User mismatch.' }, 403) + } + const storageContext = { + sessionId: session.session_id, + appId: session.app_id ?? null, + } + + try { + const resolved = await resolveSecret({ + env, + userId: user.mcpUser.userId, + name, + scope, + storageContext, + }) + if (!resolved.found) { + return jsonResponse( + { ok: false, error: 'Secret not found.' }, + 404, + ) + } + if (connector) { + const allowedHosts = + requestedAllowedHosts && requestedAllowedHosts.length > 0 + ? normalizeAllowedHosts(requestedAllowedHosts) + : resolved.allowedHosts + await saveValue({ + env, + userId: user.mcpUser.userId, + name: `_connector:${connector}`, + value: JSON.stringify({ + secretName: name, + allowedHosts, + }), + description: `Connector secret config for ${connector}`, + scope, + storageContext, + }) + } + return jsonResponse({ ok: true }) + } catch (error) { + return jsonResponse( + { + ok: false, + error: + error instanceof Error + ? error.message + : 'Unable to update connector configuration.', + }, + 400, + ) + } + }, + } satisfies BuildAction< + typeof routes.connectSecretApi.method, + typeof routes.connectSecretApi.pattern + > +} + +function readSecretScope(url: URL): SecretScope { + const raw = url.searchParams.get('scope') + return secretScopeValues.includes(raw as SecretScope) + ? (raw as SecretScope) + : 'user' +} + +function readConnectorParam(url: URL) { + const value = url.searchParams.get('connector') + return value?.trim() ? value.trim() : null +} + +function readNameParam(url: URL) { + const value = url.searchParams.get('name') + return value?.trim() ? value.trim() : null +} + +function buildConnectSecretAppId(input: { + connector: string | null + name: string | null +}) { + if (input.connector) { + return `_connector:${input.connector}` + } + return input.name ? `connect-secret:${input.name}` : null +} + +function buildHeadInjection(input: { + appSession: Awaited> + appBaseUrl: string +}) { + const stylesheetHref = resolveGeneratedUiAssetUrl( + generatedUiRuntimeStylesheetPath, + input.appBaseUrl, + ) + const runtimeScriptHref = resolveGeneratedUiAssetUrl( + generatedUiRuntimeScriptPath, + input.appBaseUrl, + ) + const bootstrap: GeneratedUiRuntimeBootstrap = { + mode: 'hosted', + appSession: { + token: input.appSession.token, + endpoints: input.appSession.endpoints, + }, + } + return ` + +${injectGeneratedUiBootstrapScript(bootstrap)} +${buildGeneratedUiRuntimeImportMap(runtimeScriptHref)} + + `.trim() +} + +function buildConnectSecretClientScript() { + return ` +import { kodyWidget, whenKodyWidgetReady } from '@kody/utils' + +const root = + document.querySelector('[data-generated-ui-root]') ?? document.body + +const state = { + step: 'loading', + error: '', + secretValue: '', + existingSecret: null, + updateConfirmed: false, + confirmedReview: false, +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function parseQuery() { + const url = new URL(window.location.href) + const name = url.searchParams.get('name') + const description = url.searchParams.get('description') + const instructions = url.searchParams.get('instructions') + const dashboardUrl = url.searchParams.get('dashboardUrl') + const connector = url.searchParams.get('connector') + const rawScope = url.searchParams.get('scope') + const scope = + rawScope === 'app' || rawScope === 'session' || rawScope === 'user' + ? rawScope + : 'user' + const allowedHosts = (url.searchParams.get('allowedHosts') || '') + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0) + const allowedCapabilities = (url.searchParams.get('allowedCapabilities') || '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + return { + name: name && name.trim() ? name.trim() : '', + description: description && description.trim() ? description.trim() : '', + instructions: instructions && instructions.trim() ? instructions.trim() : '', + dashboardUrl: dashboardUrl && dashboardUrl.trim() ? dashboardUrl.trim() : '', + connector: connector && connector.trim() ? connector.trim() : '', + scope, + allowedHosts: Array.from(new Set(allowedHosts)).sort(), + allowedCapabilities: Array.from(new Set(allowedCapabilities)).sort((a, b) => + a.localeCompare(b), + ), + } +} + +function scopeLabel(scope) { + if (scope === 'session') return 'Session (expires when this session ends)' + if (scope === 'app') return 'App' + return 'User' +} + +function renderList(items, emptyLabel) { + if (!items || items.length === 0) { + return '' + escapeHtml(emptyLabel) + '' + } + return ( + '
    ' + + items.map((item) => '
  • ' + escapeHtml(item) + '
  • ').join('') + + '
' + ) +} + +function renderInstructions(params) { + if (!params.instructions && !params.dashboardUrl) { + return '

Enter the secret value below.

' + } + const instructions = params.instructions + ? '

' + escapeHtml(params.instructions) + '

' + : '' + const dashboard = + params.dashboardUrl && isSafeUrl(params.dashboardUrl) + ? 'Open provider settings' + : '' + return ( + '
' + + instructions + + dashboard + + '
' + ) +} + +function renderExistingSecret(existing) { + if (!existing) return '' + return ( + '
' + + '

Secret already exists

' + + '

A secret named ' + + escapeHtml(existing.name) + + ' already exists in the ' + + escapeHtml(existing.scope) + + ' scope. Updating will replace the stored value.

' + + '
' + + '
' + + '
Current allowed hosts
' + + renderList(existing.allowed_hosts, 'None') + + '
' + + '
' + + '
Current allowed capabilities
' + + renderList(existing.allowed_capabilities, 'None') + + '
' + + '
' + + '
' + ) +} + +function renderReview(params) { + return ( + '
' + + '

Review before saving

' + + '
' + + '
Secret name
' + + escapeHtml(params.name) + + '
' + + '
Scope
' + + escapeHtml(scopeLabel(params.scope)) + + '
' + + '
' + + (params.description + ? '
Description
' + + escapeHtml(params.description) + + '
' + : '') + + '
' + + '
Approved hosts' + + renderList(params.allowedHosts, 'None (approval required later).') + + '
' + + '
Approved capabilities' + + renderList(params.allowedCapabilities, 'No restrictions requested.') + + '
' + + '
' + + '

The secret value stays hidden and cannot be viewed later.

' + + '
' + ) +} + +function render() { + const params = parseQuery() + if (!params.name) { + root.innerHTML = + '

Missing secret name

' + + '

Provide a name query parameter to continue.

' + return + } + + const content = [] + content.push('
') + content.push('') + + if (state.step === 'loading') { + content.push('

Loading secret details…

') + content.push('
') + root.innerHTML = content.join('') + return + } + + if (state.step === 'update-confirm' && state.existingSecret) { + content.push(renderExistingSecret(state.existingSecret)) + content.push( + '
' + + '' + + '' + + '
', + ) + content.push('') + root.innerHTML = content.join('') + attachHandlers() + return + } + + if (state.step === 'cancelled') { + content.push( + '

Cancelled

No changes were made. You can close this tab.

', + ) + content.push('') + root.innerHTML = content.join('') + return + } + + if (state.step === 'success') { + content.push( + '

Secret saved

You can close this tab now.

', + ) + content.push('') + root.innerHTML = content.join('') + return + } + + if (state.step === 'error') { + content.push( + '

Something went wrong

' + + escapeHtml(state.error || 'Unable to save the secret.') + + '

', + ) + content.push( + '
', + ) + content.push('') + root.innerHTML = content.join('') + attachHandlers() + return + } + + content.push('
') + content.push('

Instructions

') + content.push(renderInstructions(params)) + if (params.dashboardUrl && !isSafeUrl(params.dashboardUrl)) { + content.push( + '

The provided dashboard link is invalid.

', + ) + } + content.push('
') + + if (state.existingSecret && state.updateConfirmed) { + content.push(renderExistingSecret(state.existingSecret)) + } + + content.push('
') + content.push('

Enter secret

') + content.push( + '', + ) + content.push('
') + + if (state.step === 'review') { + content.push(renderReview(params)) + content.push( + '', + ) + } + + if (state.step === 'saving') { + content.push('

Saving secret…

') + } + + content.push('
') + if (state.step === 'review') { + content.push('') + content.push( + '', + ) + } else { + content.push('') + } + content.push('
') + content.push('') + root.innerHTML = content.join('') + attachHandlers() +} + +function attachHandlers() { + const params = parseQuery() + const input = root.querySelector('input[name="secretValue"]') + if (input) { + input.addEventListener('input', (event) => { + state.secretValue = event.currentTarget.value + }) + } + const confirmBox = root.querySelector('input[name="confirmReview"]') + if (confirmBox) { + confirmBox.addEventListener('change', (event) => { + state.confirmedReview = event.currentTarget.checked + render() + }) + } + root.querySelectorAll('[data-action]').forEach((button) => { + button.addEventListener('click', async (event) => { + const action = event.currentTarget.getAttribute('data-action') + if (action === 'confirm-update') { + state.updateConfirmed = true + state.step = 'input' + render() + return + } + if (action === 'cancel-update') { + state.step = 'cancelled' + render() + return + } + if (action === 'review') { + if (!state.secretValue.trim()) { + state.error = 'Enter the secret value before continuing.' + state.step = 'error' + render() + return + } + state.confirmedReview = false + state.step = 'review' + render() + return + } + if (action === 'back') { + state.step = 'input' + state.error = '' + render() + return + } + if (action === 'save') { + if (!state.confirmedReview) return + await handleSave(params) + } + }) + }) +} + +async function handleSave(params) { + state.step = 'saving' + state.error = '' + render() + try { + const saved = await kodyWidget.saveSecret({ + name: params.name, + value: state.secretValue, + description: params.description, + scope: params.scope, + }) + if (!saved.ok) { + throw new Error(saved.error || 'Unable to save secret.') + } + await updateSecretPolicies(params) + state.step = 'success' + } catch (error) { + state.error = error instanceof Error ? error.message : 'Unable to save secret.' + state.step = 'error' + } + render() +} + +async function updateSecretPolicies(params) { + if (!params.connector) return + if (params.scope !== 'app') { + throw new Error('Connector secrets must use the app scope.') + } + const sessionToken = + window.__kodyGeneratedUiBootstrap?.appSession?.token ?? null + if (!sessionToken) { + throw new Error('Missing session token. Refresh the page and retry.') + } + const response = await fetch('/connect/secret.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + name: params.name, + scope: params.scope, + sessionToken, + connector: params.connector, + allowedHosts: params.allowedHosts, + }), + }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.ok) { + const message = + typeof payload?.error === 'string' + ? payload.error + : 'Unable to update connector config.' + throw new Error(message) + } +} + +function isSafeUrl(value) { + try { + const url = new URL(value) + return url.protocol === 'https:' || url.protocol === 'http:' + } catch { + return false + } +} + +function injectStyles() { + const style = document.createElement('style') + style.textContent = [ + '.connect-secret {', + ' max-width: 720px;', + ' margin: 0 auto;', + ' display: grid;', + ' gap: var(--spacing-6);', + ' padding: var(--spacing-6);', + '}', + '.page-header { display: grid; gap: var(--spacing-2); }', + '.eyebrow {', + ' font-size: 0.75rem;', + ' text-transform: uppercase;', + ' letter-spacing: 0.08em;', + ' color: var(--color-muted);', + '}', + '.card {', + ' padding: var(--spacing-4);', + ' background: var(--color-surface);', + ' border: 1px solid var(--color-border);', + ' border-radius: var(--radius-3);', + ' box-shadow: var(--shadow-1);', + ' display: grid;', + ' gap: var(--spacing-3);', + '}', + '.label {', + ' font-weight: 600;', + ' font-size: 0.85rem;', + ' color: var(--color-muted);', + '}', + '.muted { color: var(--color-muted); }', + '.instructions { white-space: pre-wrap; }', + '.instructions-block { display: grid; gap: var(--spacing-3); }', + '.button-row { display: flex; gap: var(--spacing-3); flex-wrap: wrap; }', + 'button.secondary {', + ' background: transparent;', + ' color: var(--color-fg);', + ' border: 1px solid var(--color-border);', + '}', + '.list { margin: var(--spacing-2) 0 0; padding-left: 1.25rem; }', + '.review-grid {', + ' display: grid;', + ' grid-template-columns: repeat(2, minmax(0, 1fr));', + ' gap: var(--spacing-3);', + '}', + '.grid-two {', + ' display: grid;', + ' grid-template-columns: repeat(2, minmax(0, 1fr));', + ' gap: var(--spacing-3);', + '}', + '.confirm-row {', + ' display: flex;', + ' align-items: center;', + ' gap: var(--spacing-2);', + ' font-weight: 500;', + '}', + '@media (max-width: 640px) {', + ' .connect-secret { padding: var(--spacing-4); }', + ' .review-grid, .grid-two { grid-template-columns: 1fr; }', + '}', + ].join('\\n') + document.head.appendChild(style) +} + +async function initialize() { + injectStyles() + const params = parseQuery() + if (!params.name) { + render() + return + } + await whenKodyWidgetReady() + try { + const secrets = await kodyWidget.listSecrets({ scope: params.scope }) + const existing = secrets.find((secret) => secret.name === params.name) + state.existingSecret = existing ?? null + state.step = existing ? 'update-confirm' : 'input' + } catch (error) { + state.error = + error instanceof Error + ? error.message + : 'Unable to load existing secrets.' + state.step = 'error' + } + render() +} + +void initialize() + `.trim() +} + +function readString(body: object, key: string) { + const value = (body as Record)[key] + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function readOptionalString(body: object, key: string) { + const value = (body as Record)[key] + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function readOptionalStringArray(body: object, key: string) { + if (!Object.hasOwn(body, key)) return null + const value = (body as Record)[key] + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string') +} + +function readScope(body: object): SecretScope | null { + const raw = readString(body, 'scope') + return raw && secretScopeValues.includes(raw as SecretScope) + ? (raw as SecretScope) + : null +} + +function jsonResponse(body: Record, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json; charset=utf-8', + }, + }) +} diff --git a/packages/worker/src/app/router.ts b/packages/worker/src/app/router.ts index 39f378d1f1..f77af0983d 100644 --- a/packages/worker/src/app/router.ts +++ b/packages/worker/src/app/router.ts @@ -4,6 +4,10 @@ import { createAccountSecretsApiHandler, createAccountSecretsHandler, } from '#app/handlers/account-secrets.ts' +import { + createConnectSecretApiHandler, + createConnectSecretHandler, +} from '#app/handlers/connect-secret.ts' import { createAuthHandler } from '#app/handlers/auth.ts' import { chat } from '#app/handlers/chat.ts' import { @@ -80,6 +84,11 @@ export function createAppRouter(appEnv: AppEnv) { routes.accountSecretsApiPost, createAccountSecretsApiHandler(appEnv as Env), ) + router.map(routes.connectSecret, createConnectSecretHandler(appEnv as Env)) + router.map( + routes.connectSecretApi, + createConnectSecretApiHandler(appEnv as Env), + ) router.map(routes.savedUi, createSavedUiPageHandler(appEnv as Env)) router.map(routes.auth, createAuthHandler(appEnv)) router.map(routes.session, session) diff --git a/packages/worker/src/app/routes.ts b/packages/worker/src/app/routes.ts index 4ec4906367..6d54c5944e 100644 --- a/packages/worker/src/app/routes.ts +++ b/packages/worker/src/app/routes.ts @@ -5,6 +5,7 @@ export const routes = route({ chat: '/chat', chatThread: '/chat/:threadId', savedUi: '/ui/:id', + connectSecret: '/connect/secret', accountSecrets: '/account/secrets', accountSecretNew: '/account/secrets/new', accountSecretDetail: '/account/secrets/:secretId', @@ -14,6 +15,7 @@ export const routes = route({ accountSecretsApprove: '/account/secrets/approve', accountSecretsApi: '/account/secrets.json', accountSecretsApiPost: post('/account/secrets.json'), + connectSecretApi: post('/connect/secret.json'), chatThreads: '/chat-threads', chatThreadsCreate: post('/chat-threads'), chatThreadsUpdate: post('/chat-threads/update'), From ab1a3ac5a12f42f807da59be45047e5e5427d418 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 16:17:24 +0000 Subject: [PATCH 02/13] Align connect secret with approvals Co-authored-by: Kent C. Dodds --- packages/worker/src/app/handlers/connect-secret.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/app/handlers/connect-secret.ts b/packages/worker/src/app/handlers/connect-secret.ts index 54b1671002..685086f9b3 100644 --- a/packages/worker/src/app/handlers/connect-secret.ts +++ b/packages/worker/src/app/handlers/connect-secret.ts @@ -306,7 +306,9 @@ function renderList(items, emptyLabel) { } return ( '
    ' + - items.map((item) => '
  • ' + escapeHtml(item) + '
  • ').join('') + + items + .map((item) => '
  • ' + escapeHtml(item) + '
  • ') + .join('') + '
' ) } @@ -374,13 +376,14 @@ function renderReview(params) { '' : '') + '
' + - '
Approved hosts' + + '
Hosts to approve' + renderList(params.allowedHosts, 'None (approval required later).') + '
' + - '
Approved capabilities' + + '
Capabilities to allow' + renderList(params.allowedCapabilities, 'No restrictions requested.') + '
' + '
' + + '

Host and capability approvals are managed in account settings.

' + '

The secret value stays hidden and cannot be viewed later.

' + '
' ) From 65e13f1a11faeed284f0ce4a0dc440f0631f2551 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 16:30:24 +0000 Subject: [PATCH 03/13] Remove connector scope gate Co-authored-by: Kent C. Dodds --- packages/worker/src/app/handlers/connect-secret.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/worker/src/app/handlers/connect-secret.ts b/packages/worker/src/app/handlers/connect-secret.ts index 685086f9b3..6cf3094126 100644 --- a/packages/worker/src/app/handlers/connect-secret.ts +++ b/packages/worker/src/app/handlers/connect-secret.ts @@ -602,9 +602,6 @@ async function handleSave(params) { async function updateSecretPolicies(params) { if (!params.connector) return - if (params.scope !== 'app') { - throw new Error('Connector secrets must use the app scope.') - } const sessionToken = window.__kodyGeneratedUiBootstrap?.appSession?.token ?? null if (!sessionToken) { From 9658f49436c28694a4326689459d14c70b83fa94 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 17:19:40 +0000 Subject: [PATCH 04/13] Move connect secret to Remix route Co-authored-by: Kent C. Dodds --- .../worker/client/routes/connect-secret.tsx | 694 ++++++++++++++++++ packages/worker/client/routes/index.tsx | 2 + .../worker/src/app/handlers/connect-secret.ts | 682 ++--------------- packages/worker/src/app/router.ts | 13 +- packages/worker/src/app/routes.ts | 2 +- .../src/mcp/capabilities/coding/domain.ts | 2 + .../coding/generated-ui-secret-guide.ts | 105 +++ 7 files changed, 876 insertions(+), 624 deletions(-) create mode 100644 packages/worker/client/routes/connect-secret.tsx create mode 100644 packages/worker/src/mcp/capabilities/coding/generated-ui-secret-guide.ts diff --git a/packages/worker/client/routes/connect-secret.tsx b/packages/worker/client/routes/connect-secret.tsx new file mode 100644 index 0000000000..35bd3cb334 --- /dev/null +++ b/packages/worker/client/routes/connect-secret.tsx @@ -0,0 +1,694 @@ +import { type Handle } from 'remix/component' +import { navigate } from '#client/client-router.tsx' +import { + colors, + radius, + shadows, + spacing, + typography, +} from '#client/styles/tokens.ts' + +type StorageScope = 'app' | 'session' | 'user' +type ViewStep = + | 'loading' + | 'input' + | 'review' + | 'saving' + | 'success' + | 'error' + | 'update-confirm' + | 'cancelled' + +type SecretMetadata = { + name: string + scope: StorageScope + description: string + allowed_hosts: Array + allowed_capabilities: Array + created_at: string + updated_at: string + ttl_ms: number | null +} + +type ConnectSecretParams = { + name: string + description: string + allowedHosts: Array + allowedCapabilities: Array + scope: StorageScope + dashboardUrl: string + instructions: string + connector: string +} + +type ConnectSecretState = { + step: ViewStep + error: string + secretValue: string + existingSecret: SecretMetadata | null + updateConfirmed: boolean + confirmedReview: boolean +} + +type ConnectSecretSession = { + token: string + endpoints: { + secrets: string + deleteSecret: string + execute: string + source: string + } +} + +const defaultState: ConnectSecretState = { + step: 'loading', + error: '', + secretValue: '', + existingSecret: null, + updateConfirmed: false, + confirmedReview: false, +} + +function getSearchParams() { + return typeof window === 'undefined' + ? new URLSearchParams() + : new URLSearchParams(window.location.search) +} + +function parseScope(value: string | null): StorageScope { + return value === 'app' || value === 'session' || value === 'user' + ? value + : 'user' +} + +function parseCommaList(value: string | null, normalizer?: (item: string) => string) { + if (!value) return [] + const output = value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + return Array.from(new Set(output.map((item) => normalizer?.(item) ?? item))) +} + +function parseConnectSecretParams(): ConnectSecretParams { + const params = getSearchParams() + const name = params.get('name')?.trim() ?? '' + const description = params.get('description')?.trim() ?? '' + const instructions = params.get('instructions')?.trim() ?? '' + const dashboardUrl = params.get('dashboardUrl')?.trim() ?? '' + const connector = params.get('connector')?.trim() ?? '' + const scope = parseScope(params.get('scope')) + const allowedHosts = parseCommaList(params.get('allowedHosts'), (value) => + value.toLowerCase(), + ).sort() + const allowedCapabilities = parseCommaList(params.get('allowedCapabilities')) + .sort((left, right) => left.localeCompare(right)) + return { + name, + description, + allowedHosts, + allowedCapabilities, + scope, + dashboardUrl, + instructions, + connector, + } +} + +function scopeLabel(scope: StorageScope) { + if (scope === 'session') return 'Session (expires when this session ends)' + if (scope === 'app') return 'App' + return 'User' +} + +function isSafeUrl(value: string) { + if (!value) return false + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' || parsed.protocol === 'http:' + } catch { + return false + } +} + +async function readSessionToken() { + const url = new URL('/connect/secret.json', window.location.href) + url.search = window.location.search + const response = await fetch(url.toString(), { + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + if (response.status === 401) { + navigate(`/login?redirectTo=${encodeURIComponent(window.location.pathname + window.location.search)}`) + return null + } + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + appSession?: ConnectSecretSession + error?: string + } + if (!response.ok || !payload?.ok || !payload.appSession?.token) { + throw new Error(payload?.error || 'Unable to load secret session.') + } + return payload.appSession +} + +async function listExistingSecret( + params: ConnectSecretParams, + session: ConnectSecretSession, +): Promise { + const url = new URL(session.endpoints.secrets) + url.searchParams.set('scope', params.scope) + const response = await fetch(url.toString(), { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${session.token}`, + }, + credentials: 'omit', + }) + if (!response.ok) { + throw new Error('Unable to load existing secrets.') + } + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + secrets?: Array + } + if (!payload?.ok || !Array.isArray(payload.secrets)) { + return null + } + return ( + payload.secrets.find( + (secret) => secret.name === params.name && secret.scope === params.scope, + ) ?? null + ) +} + +async function saveSecretValue( + params: ConnectSecretParams, + session: ConnectSecretSession, + value: string, +) { + const response = await fetch(session.endpoints.secrets, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.token}`, + }, + credentials: 'omit', + body: JSON.stringify({ + name: params.name, + value, + description: params.description, + scope: params.scope, + }), + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + error?: string + } + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to save secret.') + } +} + +async function updateConnectorConfig( + params: ConnectSecretParams, + session: ConnectSecretSession, +) { + if (!params.connector) return + const response = await fetch('/connect/secret.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + name: params.name, + scope: params.scope, + sessionToken: session.token, + connector: params.connector, + allowedHosts: params.allowedHosts, + }), + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + error?: string + } + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to update connector config.') + } +} + +export function ConnectSecretRoute(handle: Handle) { + let state = { ...defaultState } + let session: ConnectSecretSession | null = null + let lastSearch = '' + + function setState(next: Partial) { + state = { ...state, ...next } + handle.update() + } + + async function initialize() { + const params = parseConnectSecretParams() + if (!params.name) { + setState({ + step: 'error', + error: 'Provide a name query parameter to continue.', + }) + return + } + try { + session = await readSessionToken() + if (!session) return + const existing = await listExistingSecret(params, session) + if (existing) { + setState({ + step: 'update-confirm', + existingSecret: existing, + }) + return + } + setState({ step: 'input' }) + } catch (error) { + setState({ + step: 'error', + error: error instanceof Error ? error.message : 'Unable to load secrets.', + }) + } + } + + async function handleSave() { + if (!session) { + setState({ + step: 'error', + error: 'Missing session token. Refresh and retry.', + }) + return + } + const params = parseConnectSecretParams() + if (!state.secretValue.trim()) { + setState({ + step: 'error', + error: 'Enter the secret value before continuing.', + }) + return + } + setState({ step: 'saving', error: '' }) + try { + await saveSecretValue(params, session, state.secretValue) + await updateConnectorConfig(params, session) + setState({ step: 'success' }) + } catch (error) { + setState({ + step: 'error', + error: error instanceof Error ? error.message : 'Unable to save secret.', + }) + } + } + + return () => { + const currentSearch = typeof window === 'undefined' ? '' : window.location.search + if (currentSearch !== lastSearch) { + lastSearch = currentSearch + handle.queueTask(() => initialize()) + } + + const params = parseConnectSecretParams() + const hasInstructions = Boolean(params.instructions || params.dashboardUrl) + const showReview = state.step === 'review' + + return ( +
+
+ + Kody secure connection + +

+ Save a secret +

+

+ {params.description || + 'This keeps credentials private and out of chat logs.'} +

+
+ + {state.step === 'loading' ? ( +

Loading secret details…

+ ) : null} + + {state.step === 'update-confirm' && state.existingSecret ? ( +
+

Secret already exists

+

+ A secret named {state.existingSecret.name} already + exists in the {state.existingSecret.scope} scope. Updating will + replace the stored value. +

+
+
+
Current allowed hosts
+
    + {state.existingSecret.allowed_hosts.length > 0 ? ( + state.existingSecret.allowed_hosts.map((host) => ( +
  • {host}
  • + )) + ) : ( +
  • None
  • + )} +
+
+
+
Current allowed capabilities
+
    + {state.existingSecret.allowed_capabilities.length > 0 ? ( + state.existingSecret.allowed_capabilities.map((cap) => ( +
  • {cap}
  • + )) + ) : ( +
  • None
  • + )} +
+
+
+
+ + +
+
+ ) : null} + + {state.step === 'cancelled' ? ( +
+

Cancelled

+

+ No changes were made. You can close this tab. +

+
+ ) : null} + + {state.step === 'success' ? ( +
+

Secret saved

+

+ You can close this tab now. +

+
+ ) : null} + + {state.step === 'error' ? ( +
+

Something went wrong

+

{state.error}

+ +
+ ) : null} + + {['input', 'review', 'saving'].includes(state.step) ? ( + <> +
+

Instructions

+ {hasInstructions ? ( + <> + {params.instructions ? ( +

+ {params.instructions} +

+ ) : null} + {params.dashboardUrl && isSafeUrl(params.dashboardUrl) ? ( + + Open provider settings + + ) : null} + {params.dashboardUrl && !isSafeUrl(params.dashboardUrl) ? ( +

+ The provided dashboard link is invalid. +

+ ) : null} + + ) : ( +

+ Enter the secret value below. +

+ )} +
+ +
+

Enter secret

+ +
+ + {showReview ? ( +
+

Review before saving

+
+
+
Secret name
+
{params.name}
+
+
+
Scope
+
{scopeLabel(params.scope)}
+
+ {params.description ? ( +
+
Description
+
{params.description}
+
+ ) : null} +
+
+
+
Hosts to approve
+
    + {params.allowedHosts.length > 0 ? ( + params.allowedHosts.map((host) => ( +
  • {host}
  • + )) + ) : ( +
  • None (approval required later).
  • + )} +
+
+
+
Capabilities to allow
+
    + {params.allowedCapabilities.length > 0 ? ( + params.allowedCapabilities.map((capability) => ( +
  • {capability}
  • + )) + ) : ( +
  • No restrictions requested.
  • + )} +
+
+
+

+ Host and capability approvals are managed in account settings. +

+

+ The secret value stays hidden and cannot be viewed later. +

+
+ ) : null} + + {showReview ? ( + + ) : null} + + {state.step === 'saving' ? ( +

Saving secret…

+ ) : null} + +
+ {showReview ? ( + <> + + + + ) : ( + + )} +
+ + ) : null} +
+ ) + } +} + +const cardCss = { + padding: spacing.lg, + borderRadius: radius.lg, + border: `1px solid ${colors.border}`, + backgroundColor: colors.surface, + boxShadow: shadows.sm, + display: 'grid', + gap: spacing.sm, +} + +const cardTitleCss = { + margin: 0, + fontSize: typography.fontSize.lg, + fontWeight: typography.fontWeight.semibold, + color: colors.text, +} + +const labelCss = { + fontWeight: typography.fontWeight.medium, + fontSize: typography.fontSize.sm, + color: colors.textMuted, +} + +const listCss = { + margin: 0, + paddingLeft: spacing.lg, + display: 'grid', + gap: spacing.xs, + color: colors.text, +} + +const inputCss = { + padding: spacing.sm, + borderRadius: radius.md, + border: `1px solid ${colors.border}`, + backgroundColor: colors.background, + color: colors.text, + fontFamily: typography.fontFamily, + fontSize: typography.fontSize.base, +} + +const primaryButtonCss = { + padding: `${spacing.sm} ${spacing.lg}`, + borderRadius: radius.full, + border: 'none', + backgroundColor: colors.primary, + color: colors.onPrimary, + fontWeight: typography.fontWeight.semibold, + cursor: 'pointer', +} + +const secondaryButtonCss = { + ...primaryButtonCss, + backgroundColor: 'transparent', + color: colors.text, + border: `1px solid ${colors.border}`, +} diff --git a/packages/worker/client/routes/index.tsx b/packages/worker/client/routes/index.tsx index f71d99c1bd..bbdc3052f9 100644 --- a/packages/worker/client/routes/index.tsx +++ b/packages/worker/client/routes/index.tsx @@ -1,6 +1,7 @@ import { AccountRoute } from './account.tsx' import { AccountSecretsRoute } from './account-secrets.tsx' import { ChatRoute } from './chat.tsx' +import { ConnectSecretRoute } from './connect-secret.tsx' import { HomeRoute } from './home.tsx' import { LoginRoute } from './login.tsx' import { OAuthAuthorizeRoute } from './oauth-authorize.tsx' @@ -13,6 +14,7 @@ export const clientRoutes = { '/chat': , '/chat/:threadId': , '/ui/:id': , + '/connect/secret': , '/account': , '/account/secrets': , '/account/secrets/new': , diff --git a/packages/worker/src/app/handlers/connect-secret.ts b/packages/worker/src/app/handlers/connect-secret.ts index 6cf3094126..3429e57862 100644 --- a/packages/worker/src/app/handlers/connect-secret.ts +++ b/packages/worker/src/app/handlers/connect-secret.ts @@ -1,71 +1,34 @@ import { type BuildAction } from 'remix/fetch-router' -import { - generatedUiRuntimeScriptPath, - generatedUiRuntimeStylesheetPath, - resolveGeneratedUiAssetUrl, -} from '@kody-internal/shared/generated-ui-asset-paths.ts' -import { renderGeneratedUiDocument } from '@kody-internal/shared/generated-ui-documents.ts' -import { - buildGeneratedUiRuntimeImportMap, - injectGeneratedUiBootstrapScript, - type GeneratedUiRuntimeBootstrap, -} from '#client/mcp-apps/generated-ui-runtime-contract.ts' +import { readAuthSessionResult } from '#app/auth-session.ts' import { readAuthenticatedAppUser } from '#app/authenticated-user.ts' import { redirectToLogin } from '#app/auth-redirect.ts' import { getAppBaseUrl } from '#app/app-base-url.ts' +import { Layout } from '#app/layout.ts' +import { render } from '#app/render.ts' +import { type routes } from '#app/routes.ts' +import { buildAccountSecretPath } from '@kody-internal/shared/account-secret-route.ts' import { createGeneratedUiAppSession, verifyGeneratedUiAppSession, } from '#mcp/generated-ui-app-session.ts' -import { - listSecrets, - resolveSecret, -} from '#mcp/secrets/service.ts' import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts' +import { resolveSecret } from '#mcp/secrets/service.ts' import { secretScopeValues, type SecretScope } from '#mcp/secrets/types.ts' import { saveValue } from '#mcp/values/service.ts' -import { type routes } from '#app/routes.ts' -export function createConnectSecretHandler(env: Env) { +export function createConnectSecretHandler(_env: Env) { return { middleware: [], async action({ request }) { - const user = await readAuthenticatedAppUser(request, env) - if (!user) { + const { session, setCookie } = await readAuthSessionResult(request) + if (!session) { return redirectToLogin(request) } - - const url = new URL(request.url) - const scope = readSecretScope(url) - const connector = readConnectorParam(url) - const name = readNameParam(url) - const appId = - scope === 'app' - ? buildConnectSecretAppId({ connector, name }) - : null - const baseUrl = getAppBaseUrl({ env, requestUrl: request.url }) - const appSession = await createGeneratedUiAppSession({ - env, - baseUrl, - user: user.mcpUser, - appId, - homeConnectorId: null, - }) - const html = renderGeneratedUiDocument({ - code: buildConnectSecretClientScript(), - runtime: 'javascript', - headInjection: buildHeadInjection({ - appSession, - appBaseUrl: baseUrl, - }), - baseHref: baseUrl, - }) - return new Response(html, { - headers: { - 'Cache-Control': 'no-store', - 'Content-Type': 'text/html; charset=utf-8', - }, - }) + const response = render(Layout({ title: 'Connect secret' })) + if (setCookie) { + response.headers.set('Set-Cookie', setCookie) + } + return response }, } satisfies BuildAction< typeof routes.connectSecret.method, @@ -77,20 +40,53 @@ export function createConnectSecretApiHandler(env: Env) { return { middleware: [], async action({ request }) { - if (request.method !== 'POST') { - return jsonResponse({ ok: false, error: 'Method not allowed.' }, 405) - } const user = await readAuthenticatedAppUser(request, env) if (!user) { return jsonResponse({ ok: false, error: 'Unauthorized.' }, 401) } + + if (request.method === 'GET') { + const url = new URL(request.url) + const scope = readSecretScope(url) + const connector = readOptionalStringParam(url, 'connector') + const name = readOptionalStringParam(url, 'name') + const appId = + scope === 'app' + ? buildConnectSecretAppId({ connector, name }) + : null + const baseUrl = getAppBaseUrl({ env, requestUrl: request.url }) + const appSession = await createGeneratedUiAppSession({ + env, + baseUrl, + user: user.mcpUser, + appId, + homeConnectorId: null, + }) + return jsonResponse({ + ok: true, + appSession: { + token: appSession.token, + sessionId: appSession.sessionId, + endpoints: appSession.endpoints, + }, + }) + } + + if (request.method !== 'POST') { + return jsonResponse({ ok: false, error: 'Method not allowed.' }, 405) + } + const body = await request.json().catch(() => null) if (!body || typeof body !== 'object') { return jsonResponse({ ok: false, error: 'Invalid request body.' }, 400) } + const name = readString(body, 'name') const scope = readScope(body) const sessionToken = readString(body, 'sessionToken') + const connector = readOptionalString(body, 'connector') + const requestedAllowedHosts = + readOptionalStringArray(body, 'allowedHosts') ?? [] if (!name) { return jsonResponse({ ok: false, error: 'Secret name is required.' }, 400) } @@ -103,9 +99,7 @@ export function createConnectSecretApiHandler(env: Env) { 400, ) } - const connector = readOptionalString(body, 'connector') - const requestedAllowedHosts = - readOptionalStringArray(body, 'allowedHosts') ?? [] + let session try { session = await verifyGeneratedUiAppSession(env, sessionToken) @@ -124,28 +118,26 @@ export function createConnectSecretApiHandler(env: Env) { if (session.user.userId !== user.mcpUser.userId) { return jsonResponse({ ok: false, error: 'User mismatch.' }, 403) } + const storageContext = { sessionId: session.session_id, appId: session.app_id ?? null, } try { - const resolved = await resolveSecret({ - env, - userId: user.mcpUser.userId, - name, - scope, - storageContext, - }) - if (!resolved.found) { - return jsonResponse( - { ok: false, error: 'Secret not found.' }, - 404, - ) - } if (connector) { + const resolved = await resolveSecret({ + env, + userId: user.mcpUser.userId, + name, + scope, + storageContext, + }) + if (!resolved.found) { + return jsonResponse({ ok: false, error: 'Secret not found.' }, 404) + } const allowedHosts = - requestedAllowedHosts && requestedAllowedHosts.length > 0 + requestedAllowedHosts.length > 0 ? normalizeAllowedHosts(requestedAllowedHosts) : resolved.allowedHosts await saveValue({ @@ -182,557 +174,17 @@ export function createConnectSecretApiHandler(env: Env) { } function readSecretScope(url: URL): SecretScope { - const raw = url.searchParams.get('scope') - return secretScopeValues.includes(raw as SecretScope) + const raw = readOptionalStringParam(url, 'scope') + return raw && secretScopeValues.includes(raw as SecretScope) ? (raw as SecretScope) : 'user' } -function readConnectorParam(url: URL) { - const value = url.searchParams.get('connector') - return value?.trim() ? value.trim() : null -} - -function readNameParam(url: URL) { - const value = url.searchParams.get('name') +function readOptionalStringParam(url: URL, key: string) { + const value = url.searchParams.get(key) return value?.trim() ? value.trim() : null } -function buildConnectSecretAppId(input: { - connector: string | null - name: string | null -}) { - if (input.connector) { - return `_connector:${input.connector}` - } - return input.name ? `connect-secret:${input.name}` : null -} - -function buildHeadInjection(input: { - appSession: Awaited> - appBaseUrl: string -}) { - const stylesheetHref = resolveGeneratedUiAssetUrl( - generatedUiRuntimeStylesheetPath, - input.appBaseUrl, - ) - const runtimeScriptHref = resolveGeneratedUiAssetUrl( - generatedUiRuntimeScriptPath, - input.appBaseUrl, - ) - const bootstrap: GeneratedUiRuntimeBootstrap = { - mode: 'hosted', - appSession: { - token: input.appSession.token, - endpoints: input.appSession.endpoints, - }, - } - return ` - -${injectGeneratedUiBootstrapScript(bootstrap)} -${buildGeneratedUiRuntimeImportMap(runtimeScriptHref)} - - `.trim() -} - -function buildConnectSecretClientScript() { - return ` -import { kodyWidget, whenKodyWidgetReady } from '@kody/utils' - -const root = - document.querySelector('[data-generated-ui-root]') ?? document.body - -const state = { - step: 'loading', - error: '', - secretValue: '', - existingSecret: null, - updateConfirmed: false, - confirmedReview: false, -} - -function escapeHtml(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -function parseQuery() { - const url = new URL(window.location.href) - const name = url.searchParams.get('name') - const description = url.searchParams.get('description') - const instructions = url.searchParams.get('instructions') - const dashboardUrl = url.searchParams.get('dashboardUrl') - const connector = url.searchParams.get('connector') - const rawScope = url.searchParams.get('scope') - const scope = - rawScope === 'app' || rawScope === 'session' || rawScope === 'user' - ? rawScope - : 'user' - const allowedHosts = (url.searchParams.get('allowedHosts') || '') - .split(',') - .map((entry) => entry.trim().toLowerCase()) - .filter((entry) => entry.length > 0) - const allowedCapabilities = (url.searchParams.get('allowedCapabilities') || '') - .split(',') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - return { - name: name && name.trim() ? name.trim() : '', - description: description && description.trim() ? description.trim() : '', - instructions: instructions && instructions.trim() ? instructions.trim() : '', - dashboardUrl: dashboardUrl && dashboardUrl.trim() ? dashboardUrl.trim() : '', - connector: connector && connector.trim() ? connector.trim() : '', - scope, - allowedHosts: Array.from(new Set(allowedHosts)).sort(), - allowedCapabilities: Array.from(new Set(allowedCapabilities)).sort((a, b) => - a.localeCompare(b), - ), - } -} - -function scopeLabel(scope) { - if (scope === 'session') return 'Session (expires when this session ends)' - if (scope === 'app') return 'App' - return 'User' -} - -function renderList(items, emptyLabel) { - if (!items || items.length === 0) { - return '' + escapeHtml(emptyLabel) + '' - } - return ( - '
    ' + - items - .map((item) => '
  • ' + escapeHtml(item) + '
  • ') - .join('') + - '
' - ) -} - -function renderInstructions(params) { - if (!params.instructions && !params.dashboardUrl) { - return '

Enter the secret value below.

' - } - const instructions = params.instructions - ? '

' + escapeHtml(params.instructions) + '

' - : '' - const dashboard = - params.dashboardUrl && isSafeUrl(params.dashboardUrl) - ? 'Open provider settings' - : '' - return ( - '
' + - instructions + - dashboard + - '
' - ) -} - -function renderExistingSecret(existing) { - if (!existing) return '' - return ( - '
' + - '

Secret already exists

' + - '

A secret named ' + - escapeHtml(existing.name) + - ' already exists in the ' + - escapeHtml(existing.scope) + - ' scope. Updating will replace the stored value.

' + - '
' + - '
' + - '
Current allowed hosts
' + - renderList(existing.allowed_hosts, 'None') + - '
' + - '
' + - '
Current allowed capabilities
' + - renderList(existing.allowed_capabilities, 'None') + - '
' + - '
' + - '
' - ) -} - -function renderReview(params) { - return ( - '
' + - '

Review before saving

' + - '
' + - '
Secret name
' + - escapeHtml(params.name) + - '
' + - '
Scope
' + - escapeHtml(scopeLabel(params.scope)) + - '
' + - '
' + - (params.description - ? '
Description
' + - escapeHtml(params.description) + - '
' - : '') + - '
' + - '
Hosts to approve' + - renderList(params.allowedHosts, 'None (approval required later).') + - '
' + - '
Capabilities to allow' + - renderList(params.allowedCapabilities, 'No restrictions requested.') + - '
' + - '
' + - '

Host and capability approvals are managed in account settings.

' + - '

The secret value stays hidden and cannot be viewed later.

' + - '
' - ) -} - -function render() { - const params = parseQuery() - if (!params.name) { - root.innerHTML = - '

Missing secret name

' + - '

Provide a name query parameter to continue.

' - return - } - - const content = [] - content.push('
') - content.push('') - - if (state.step === 'loading') { - content.push('

Loading secret details…

') - content.push('
') - root.innerHTML = content.join('') - return - } - - if (state.step === 'update-confirm' && state.existingSecret) { - content.push(renderExistingSecret(state.existingSecret)) - content.push( - '
' + - '' + - '' + - '
', - ) - content.push('
') - root.innerHTML = content.join('') - attachHandlers() - return - } - - if (state.step === 'cancelled') { - content.push( - '

Cancelled

No changes were made. You can close this tab.

', - ) - content.push('') - root.innerHTML = content.join('') - return - } - - if (state.step === 'success') { - content.push( - '

Secret saved

You can close this tab now.

', - ) - content.push('') - root.innerHTML = content.join('') - return - } - - if (state.step === 'error') { - content.push( - '

Something went wrong

' + - escapeHtml(state.error || 'Unable to save the secret.') + - '

', - ) - content.push( - '
', - ) - content.push('') - root.innerHTML = content.join('') - attachHandlers() - return - } - - content.push('
') - content.push('

Instructions

') - content.push(renderInstructions(params)) - if (params.dashboardUrl && !isSafeUrl(params.dashboardUrl)) { - content.push( - '

The provided dashboard link is invalid.

', - ) - } - content.push('
') - - if (state.existingSecret && state.updateConfirmed) { - content.push(renderExistingSecret(state.existingSecret)) - } - - content.push('
') - content.push('

Enter secret

') - content.push( - '', - ) - content.push('
') - - if (state.step === 'review') { - content.push(renderReview(params)) - content.push( - '', - ) - } - - if (state.step === 'saving') { - content.push('

Saving secret…

') - } - - content.push('
') - if (state.step === 'review') { - content.push('') - content.push( - '', - ) - } else { - content.push('') - } - content.push('
') - content.push('') - root.innerHTML = content.join('') - attachHandlers() -} - -function attachHandlers() { - const params = parseQuery() - const input = root.querySelector('input[name="secretValue"]') - if (input) { - input.addEventListener('input', (event) => { - state.secretValue = event.currentTarget.value - }) - } - const confirmBox = root.querySelector('input[name="confirmReview"]') - if (confirmBox) { - confirmBox.addEventListener('change', (event) => { - state.confirmedReview = event.currentTarget.checked - render() - }) - } - root.querySelectorAll('[data-action]').forEach((button) => { - button.addEventListener('click', async (event) => { - const action = event.currentTarget.getAttribute('data-action') - if (action === 'confirm-update') { - state.updateConfirmed = true - state.step = 'input' - render() - return - } - if (action === 'cancel-update') { - state.step = 'cancelled' - render() - return - } - if (action === 'review') { - if (!state.secretValue.trim()) { - state.error = 'Enter the secret value before continuing.' - state.step = 'error' - render() - return - } - state.confirmedReview = false - state.step = 'review' - render() - return - } - if (action === 'back') { - state.step = 'input' - state.error = '' - render() - return - } - if (action === 'save') { - if (!state.confirmedReview) return - await handleSave(params) - } - }) - }) -} - -async function handleSave(params) { - state.step = 'saving' - state.error = '' - render() - try { - const saved = await kodyWidget.saveSecret({ - name: params.name, - value: state.secretValue, - description: params.description, - scope: params.scope, - }) - if (!saved.ok) { - throw new Error(saved.error || 'Unable to save secret.') - } - await updateSecretPolicies(params) - state.step = 'success' - } catch (error) { - state.error = error instanceof Error ? error.message : 'Unable to save secret.' - state.step = 'error' - } - render() -} - -async function updateSecretPolicies(params) { - if (!params.connector) return - const sessionToken = - window.__kodyGeneratedUiBootstrap?.appSession?.token ?? null - if (!sessionToken) { - throw new Error('Missing session token. Refresh the page and retry.') - } - const response = await fetch('/connect/secret.json', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ - name: params.name, - scope: params.scope, - sessionToken, - connector: params.connector, - allowedHosts: params.allowedHosts, - }), - }) - const payload = await response.json().catch(() => null) - if (!response.ok || !payload?.ok) { - const message = - typeof payload?.error === 'string' - ? payload.error - : 'Unable to update connector config.' - throw new Error(message) - } -} - -function isSafeUrl(value) { - try { - const url = new URL(value) - return url.protocol === 'https:' || url.protocol === 'http:' - } catch { - return false - } -} - -function injectStyles() { - const style = document.createElement('style') - style.textContent = [ - '.connect-secret {', - ' max-width: 720px;', - ' margin: 0 auto;', - ' display: grid;', - ' gap: var(--spacing-6);', - ' padding: var(--spacing-6);', - '}', - '.page-header { display: grid; gap: var(--spacing-2); }', - '.eyebrow {', - ' font-size: 0.75rem;', - ' text-transform: uppercase;', - ' letter-spacing: 0.08em;', - ' color: var(--color-muted);', - '}', - '.card {', - ' padding: var(--spacing-4);', - ' background: var(--color-surface);', - ' border: 1px solid var(--color-border);', - ' border-radius: var(--radius-3);', - ' box-shadow: var(--shadow-1);', - ' display: grid;', - ' gap: var(--spacing-3);', - '}', - '.label {', - ' font-weight: 600;', - ' font-size: 0.85rem;', - ' color: var(--color-muted);', - '}', - '.muted { color: var(--color-muted); }', - '.instructions { white-space: pre-wrap; }', - '.instructions-block { display: grid; gap: var(--spacing-3); }', - '.button-row { display: flex; gap: var(--spacing-3); flex-wrap: wrap; }', - 'button.secondary {', - ' background: transparent;', - ' color: var(--color-fg);', - ' border: 1px solid var(--color-border);', - '}', - '.list { margin: var(--spacing-2) 0 0; padding-left: 1.25rem; }', - '.review-grid {', - ' display: grid;', - ' grid-template-columns: repeat(2, minmax(0, 1fr));', - ' gap: var(--spacing-3);', - '}', - '.grid-two {', - ' display: grid;', - ' grid-template-columns: repeat(2, minmax(0, 1fr));', - ' gap: var(--spacing-3);', - '}', - '.confirm-row {', - ' display: flex;', - ' align-items: center;', - ' gap: var(--spacing-2);', - ' font-weight: 500;', - '}', - '@media (max-width: 640px) {', - ' .connect-secret { padding: var(--spacing-4); }', - ' .review-grid, .grid-two { grid-template-columns: 1fr; }', - '}', - ].join('\\n') - document.head.appendChild(style) -} - -async function initialize() { - injectStyles() - const params = parseQuery() - if (!params.name) { - render() - return - } - await whenKodyWidgetReady() - try { - const secrets = await kodyWidget.listSecrets({ scope: params.scope }) - const existing = secrets.find((secret) => secret.name === params.name) - state.existingSecret = existing ?? null - state.step = existing ? 'update-confirm' : 'input' - } catch (error) { - state.error = - error instanceof Error - ? error.message - : 'Unable to load existing secrets.' - state.step = 'error' - } - render() -} - -void initialize() - `.trim() -} - function readString(body: object, key: string) { const value = (body as Record)[key] return typeof value === 'string' && value.trim() ? value.trim() : null diff --git a/packages/worker/src/app/router.ts b/packages/worker/src/app/router.ts index f77af0983d..6d138d867e 100644 --- a/packages/worker/src/app/router.ts +++ b/packages/worker/src/app/router.ts @@ -4,10 +4,6 @@ import { createAccountSecretsApiHandler, createAccountSecretsHandler, } from '#app/handlers/account-secrets.ts' -import { - createConnectSecretApiHandler, - createConnectSecretHandler, -} from '#app/handlers/connect-secret.ts' import { createAuthHandler } from '#app/handlers/auth.ts' import { chat } from '#app/handlers/chat.ts' import { @@ -23,6 +19,10 @@ import { createPasswordResetConfirmHandler, createPasswordResetRequestHandler, } from '#app/handlers/password-reset.ts' +import { + createConnectSecretApiHandler, + createConnectSecretHandler, +} from '#app/handlers/connect-secret.ts' import { createSavedUiPageHandler } from '#app/handlers/saved-ui.ts' import { session } from '#app/handlers/session.ts' import { signup } from '#app/handlers/signup.ts' @@ -85,10 +85,7 @@ export function createAppRouter(appEnv: AppEnv) { createAccountSecretsApiHandler(appEnv as Env), ) router.map(routes.connectSecret, createConnectSecretHandler(appEnv as Env)) - router.map( - routes.connectSecretApi, - createConnectSecretApiHandler(appEnv as Env), - ) + router.map(routes.connectSecretApi, createConnectSecretApiHandler(appEnv as Env)) router.map(routes.savedUi, createSavedUiPageHandler(appEnv as Env)) router.map(routes.auth, createAuthHandler(appEnv)) router.map(routes.session, session) diff --git a/packages/worker/src/app/routes.ts b/packages/worker/src/app/routes.ts index 6d54c5944e..cea26774cc 100644 --- a/packages/worker/src/app/routes.ts +++ b/packages/worker/src/app/routes.ts @@ -6,6 +6,7 @@ export const routes = route({ chatThread: '/chat/:threadId', savedUi: '/ui/:id', connectSecret: '/connect/secret', + connectSecretApi: '/connect/secret.json', accountSecrets: '/account/secrets', accountSecretNew: '/account/secrets/new', accountSecretDetail: '/account/secrets/:secretId', @@ -15,7 +16,6 @@ export const routes = route({ accountSecretsApprove: '/account/secrets/approve', accountSecretsApi: '/account/secrets.json', accountSecretsApiPost: post('/account/secrets.json'), - connectSecretApi: post('/connect/secret.json'), chatThreads: '/chat-threads', chatThreadsCreate: post('/chat-threads'), chatThreadsUpdate: post('/chat-threads/update'), diff --git a/packages/worker/src/mcp/capabilities/coding/domain.ts b/packages/worker/src/mcp/capabilities/coding/domain.ts index bfadecdb41..f58df32480 100644 --- a/packages/worker/src/mcp/capabilities/coding/domain.ts +++ b/packages/worker/src/mcp/capabilities/coding/domain.ts @@ -5,6 +5,7 @@ import { cloudflareRestCapability } from './cloudflare-rest.ts' import { cursorCloudAgentDocsCapability } from './cursor-cloud-agent-docs.ts' import { cursorCloudRestCapability } from './cursor-cloud-rest.ts' import { generatedUiOAuthGuideCapability } from './generated-ui-oauth-guide.ts' +import { generatedUiSecretGuideCapability } from './generated-ui-secret-guide.ts' import { githubGraphqlApiDocsCapability } from './github-graphql-api-docs.ts' import { githubGraphqlCapability } from './github-graphql.ts' import { githubRestApiDocsCapability } from './github-rest-api-docs.ts' @@ -20,6 +21,7 @@ export const codingDomain = defineDomain({ githubGraphqlCapability, githubGraphqlApiDocsCapability, generatedUiOAuthGuideCapability, + generatedUiSecretGuideCapability, cursorCloudRestCapability, cursorCloudAgentDocsCapability, cloudflareRestCapability, diff --git a/packages/worker/src/mcp/capabilities/coding/generated-ui-secret-guide.ts b/packages/worker/src/mcp/capabilities/coding/generated-ui-secret-guide.ts new file mode 100644 index 0000000000..111d8b44f7 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/coding/generated-ui-secret-guide.ts @@ -0,0 +1,105 @@ +import { z } from 'zod' +import { defineDomainCapability } from '../define-domain-capability.ts' +import { capabilityDomainNames } from '../domain-metadata.ts' +import { type CapabilityContext } from '../types.ts' + +const inputSchema = z + .object({}) + .describe( + 'No input. Returns the Kody guide for using the /connect/secret page to collect API keys and personal access tokens.', + ) + +const outputSchema = z.object({ + title: z.string().describe('Guide title.'), + body: z.string().describe('Markdown guidance for /connect/secret usage.'), +}) + +const guideBody = ` +# Connect secret guide + +Use the hosted **/connect/secret** page whenever the user needs to enter a +secret value such as an API key or personal access token. The agent must never +see the secret value. + +## When to use /connect/secret + +Use it when: + +- you need the user to provide a sensitive value +- a capability requires a secret placeholder that is missing +- rotating a stored secret value + +Do **not** ask the user to paste secrets into chat. + +## URL format + +Provide the user a URL like: + +\`\`\` +https://heykody.dev/connect/secret? + name=linearApiKey + &description=Linear API key for issue management + &allowedHosts=api.linear.app + &scope=user + &dashboardUrl=https://linear.app/settings/api + &instructions=Go to Linear Settings → API → Personal API Keys → Create key + &allowedCapabilities=linear_issue_list,linear_issue_create + &connector=linear +\`\`\` + +## Query params + +| Param | Required | Description | +| --- | --- | --- | +| \`name\` | yes | Secret name (e.g. \`linearApiKey\`). | +| \`description\` | no | Human-readable description shown in the UI. | +| \`allowedHosts\` | no | Comma-separated hosts to review for approval. | +| \`allowedCapabilities\` | no | Comma-separated capability names to review. | +| \`scope\` | no | \`user\` (default), \`session\`, or \`app\`. | +| \`dashboardUrl\` | no | Provider settings link for creating the key. | +| \`instructions\` | no | Step-by-step instructions shown on the page. | +| \`connector\` | no | Writes \`_connector:{connector}\` config on save. | + +## Approval policy reminders + +- Saving a secret does **not** approve outbound hosts. +- The connect page only shows the requested hosts/capabilities for review. +- Host and capability approvals must be handled in the authenticated account + secrets UI after the secret is saved. + +## Agent instructions + +1. Generate the URL with the required \`name\` and any optional params. +2. Ask the user to open the URL in their browser. +3. Wait until they confirm the secret is saved. +4. Proceed using \`{{secret:name}}\` placeholders or the relevant capability. +`.trim() + +export const generatedUiSecretGuideCapability = defineDomainCapability( + capabilityDomainNames.coding, + { + name: 'generated_ui_secret_guide', + description: + 'Read the guide for using the hosted /connect/secret page to collect API keys and personal access tokens safely.', + keywords: [ + 'secret', + 'api key', + 'personal access token', + 'connect secret', + 'generated ui', + 'hosted page', + 'credentials', + ], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema, + outputSchema, + async handler(_args, _ctx: CapabilityContext) { + return { + title: 'Connect secret guide', + body: guideBody, + } + }, + }, +) From 13040dc7a5c236d8b82bb24b8b8ebfc9edcfbde9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 17:51:21 +0000 Subject: [PATCH 05/13] Clarify allowed capability guidance Co-authored-by: Kent C. Dodds --- .../src/mcp/capabilities/coding/generated-ui-secret-guide.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/mcp/capabilities/coding/generated-ui-secret-guide.ts b/packages/worker/src/mcp/capabilities/coding/generated-ui-secret-guide.ts index 111d8b44f7..e37b6b121b 100644 --- a/packages/worker/src/mcp/capabilities/coding/generated-ui-secret-guide.ts +++ b/packages/worker/src/mcp/capabilities/coding/generated-ui-secret-guide.ts @@ -54,7 +54,7 @@ https://heykody.dev/connect/secret? | \`name\` | yes | Secret name (e.g. \`linearApiKey\`). | | \`description\` | no | Human-readable description shown in the UI. | | \`allowedHosts\` | no | Comma-separated hosts to review for approval. | -| \`allowedCapabilities\` | no | Comma-separated capability names to review. | +| \`allowedCapabilities\` | no | Comma-separated capability names to review. Use only real Kody capability names (discoverable via search or meta_list_capabilities). | | \`scope\` | no | \`user\` (default), \`session\`, or \`app\`. | | \`dashboardUrl\` | no | Provider settings link for creating the key. | | \`instructions\` | no | Step-by-step instructions shown on the page. | @@ -70,6 +70,8 @@ https://heykody.dev/connect/secret? ## Agent instructions 1. Generate the URL with the required \`name\` and any optional params. + - Only include \`allowedCapabilities\` when you have confirmed the capability + names exist in Kody (use \`search\` or \`meta_list_capabilities\`). 2. Ask the user to open the URL in their browser. 3. Wait until they confirm the secret is saved. 4. Proceed using \`{{secret:name}}\` placeholders or the relevant capability. From a8bbde8c7bdee16472d7d274d735f6b815973124 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 18:00:24 +0000 Subject: [PATCH 06/13] Fix connect secret initialization and input parsing Co-authored-by: Kent C. Dodds --- packages/worker/client/routes/connect-secret.tsx | 2 +- packages/worker/src/app/handlers/connect-secret.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/worker/client/routes/connect-secret.tsx b/packages/worker/client/routes/connect-secret.tsx index 35bd3cb334..7e83be96c2 100644 --- a/packages/worker/client/routes/connect-secret.tsx +++ b/packages/worker/client/routes/connect-secret.tsx @@ -244,7 +244,7 @@ async function updateConnectorConfig( export function ConnectSecretRoute(handle: Handle) { let state = { ...defaultState } let session: ConnectSecretSession | null = null - let lastSearch = '' + let lastSearch: string | null = null function setState(next: Partial) { state = { ...state, ...next } diff --git a/packages/worker/src/app/handlers/connect-secret.ts b/packages/worker/src/app/handlers/connect-secret.ts index 3429e57862..b522f2f5d0 100644 --- a/packages/worker/src/app/handlers/connect-secret.ts +++ b/packages/worker/src/app/handlers/connect-secret.ts @@ -192,7 +192,7 @@ function readString(body: object, key: string) { function readOptionalString(body: object, key: string) { const value = (body as Record)[key] - return typeof value === 'string' && value.trim() ? value.trim() : null + return typeof value === 'string' ? value.trim() : null } function readOptionalStringArray(body: object, key: string) { From bfbd7e26c51286522a97bce926c8c3a12fc1ceea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 18:11:18 +0000 Subject: [PATCH 07/13] Fix connect secret error recovery Co-authored-by: Kent C. Dodds --- packages/worker/client/routes/connect-secret.tsx | 11 ++++++++++- packages/worker/src/app/handlers/connect-secret.ts | 1 - 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/worker/client/routes/connect-secret.tsx b/packages/worker/client/routes/connect-secret.tsx index 7e83be96c2..60ff28855c 100644 --- a/packages/worker/client/routes/connect-secret.tsx +++ b/packages/worker/client/routes/connect-secret.tsx @@ -309,6 +309,15 @@ export function ConnectSecretRoute(handle: Handle) { } } + function handleErrorBack() { + if (!session) { + setState({ step: 'loading', error: '' }) + handle.queueTask(() => initialize()) + return + } + setState({ step: 'input', error: '' }) + } + return () => { const currentSearch = typeof window === 'undefined' ? '' : window.location.search if (currentSearch !== lastSearch) { @@ -444,7 +453,7 @@ export function ConnectSecretRoute(handle: Handle) { diff --git a/packages/worker/src/app/handlers/connect-secret.ts b/packages/worker/src/app/handlers/connect-secret.ts index b522f2f5d0..286b584006 100644 --- a/packages/worker/src/app/handlers/connect-secret.ts +++ b/packages/worker/src/app/handlers/connect-secret.ts @@ -6,7 +6,6 @@ import { getAppBaseUrl } from '#app/app-base-url.ts' import { Layout } from '#app/layout.ts' import { render } from '#app/render.ts' import { type routes } from '#app/routes.ts' -import { buildAccountSecretPath } from '@kody-internal/shared/account-secret-route.ts' import { createGeneratedUiAppSession, verifyGeneratedUiAppSession, From e868899ca3de3c24eda73cfbc7fe03faacd8262d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 18:19:29 +0000 Subject: [PATCH 08/13] Fix connect secret review and capabilities Co-authored-by: Kent C. Dodds --- packages/worker/client/routes/connect-secret.tsx | 3 ++- packages/worker/src/app/handlers/connect-secret.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/worker/client/routes/connect-secret.tsx b/packages/worker/client/routes/connect-secret.tsx index 60ff28855c..f0a4e9d0c8 100644 --- a/packages/worker/client/routes/connect-secret.tsx +++ b/packages/worker/client/routes/connect-secret.tsx @@ -230,6 +230,7 @@ async function updateConnectorConfig( sessionToken: session.token, connector: params.connector, allowedHosts: params.allowedHosts, + allowedCapabilities: params.allowedCapabilities, }), }) const payload = (await response.json().catch(() => null)) as { @@ -327,7 +328,7 @@ export function ConnectSecretRoute(handle: Handle) { const params = parseConnectSecretParams() const hasInstructions = Boolean(params.instructions || params.dashboardUrl) - const showReview = state.step === 'review' + const showReview = state.step === 'review' || state.step === 'saving' return (
0 ? normalizeAllowedHosts(requestedAllowedHosts) : resolved.allowedHosts + const allowedCapabilities = + requestedAllowedCapabilities.length > 0 + ? requestedAllowedCapabilities + : resolved.allowedCapabilities await saveValue({ env, userId: user.mcpUser.userId, @@ -146,6 +152,7 @@ export function createConnectSecretApiHandler(env: Env) { value: JSON.stringify({ secretName: name, allowedHosts, + allowedCapabilities, }), description: `Connector secret config for ${connector}`, scope, From 28b6f4e878bb7199c6ef0f015a47b183764058df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 18:32:39 +0000 Subject: [PATCH 09/13] Fix connect secret CI issues Co-authored-by: Kent C. Dodds --- packages/worker/client/routes/connect-secret.tsx | 1 + packages/worker/src/app/handlers/connect-secret.ts | 7 +------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/worker/client/routes/connect-secret.tsx b/packages/worker/client/routes/connect-secret.tsx index f0a4e9d0c8..c772db5ab5 100644 --- a/packages/worker/client/routes/connect-secret.tsx +++ b/packages/worker/client/routes/connect-secret.tsx @@ -197,6 +197,7 @@ async function saveSecretValue( }, credentials: 'omit', body: JSON.stringify({ + action: 'save', name: params.name, value, description: params.description, diff --git a/packages/worker/src/app/handlers/connect-secret.ts b/packages/worker/src/app/handlers/connect-secret.ts index 28302cb20e..ceb5be2eb4 100644 --- a/packages/worker/src/app/handlers/connect-secret.ts +++ b/packages/worker/src/app/handlers/connect-secret.ts @@ -47,12 +47,7 @@ export function createConnectSecretApiHandler(env: Env) { if (request.method === 'GET') { const url = new URL(request.url) const scope = readSecretScope(url) - const connector = readOptionalStringParam(url, 'connector') - const name = readOptionalStringParam(url, 'name') - const appId = - scope === 'app' - ? buildConnectSecretAppId({ connector, name }) - : null + const appId = scope === 'app' ? session.sessionId : null const baseUrl = getAppBaseUrl({ env, requestUrl: request.url }) const appSession = await createGeneratedUiAppSession({ env, From c5dde0626c6cc7be3e40d029ac7e31f5ea7f501d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Mar 2026 19:27:01 +0000 Subject: [PATCH 10/13] Harden connect secret flow Co-authored-by: Kent C. Dodds --- .../worker/client/routes/connect-secret.tsx | 139 ++++++++++++++++-- .../worker/src/app/handlers/connect-secret.ts | 15 +- 2 files changed, 144 insertions(+), 10 deletions(-) diff --git a/packages/worker/client/routes/connect-secret.tsx b/packages/worker/client/routes/connect-secret.tsx index c772db5ab5..ea0366239a 100644 --- a/packages/worker/client/routes/connect-secret.tsx +++ b/packages/worker/client/routes/connect-secret.tsx @@ -213,6 +213,30 @@ async function saveSecretValue( } } +async function deleteSecretValue( + params: ConnectSecretParams, + session: ConnectSecretSession, +) { + const response = await fetch(session.endpoints.deleteSecret, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.token}`, + }, + credentials: 'omit', + body: JSON.stringify({ + name: params.name, + scope: params.scope, + }), + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + deleted?: boolean + } + return Boolean(response.ok && payload?.ok && payload.deleted) +} + async function updateConnectorConfig( params: ConnectSecretParams, session: ConnectSecretSession, @@ -243,19 +267,81 @@ async function updateConnectorConfig( } } +async function rollbackSecretValue( + params: ConnectSecretParams, + session: ConnectSecretSession, +) { + const response = await fetch(session.endpoints.deleteSecret, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.token}`, + }, + credentials: 'omit', + body: JSON.stringify({ + name: params.name, + scope: params.scope, + }), + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + error?: string + } + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to rollback secret.') + } +} + +async function rollbackSecretValue( + params: ConnectSecretParams, + session: ConnectSecretSession, +) { + const response = await fetch(session.endpoints.deleteSecret, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.token}`, + }, + credentials: 'omit', + body: JSON.stringify({ + name: params.name, + scope: params.scope, + }), + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + error?: string + } + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to rollback secret.') + } +} + export function ConnectSecretRoute(handle: Handle) { let state = { ...defaultState } let session: ConnectSecretSession | null = null let lastSearch: string | null = null + let initVersion = 0 function setState(next: Partial) { state = { ...state, ...next } handle.update() } - async function initialize() { + function startInitialization() { + session = null + initVersion += 1 + const version = initVersion + setState({ ...defaultState, step: 'loading' }) + handle.queueTask(() => initialize(version)) + } + + async function initialize(version: number) { const params = parseConnectSecretParams() if (!params.name) { + if (version !== initVersion) return setState({ step: 'error', error: 'Provide a name query parameter to continue.', @@ -263,9 +349,11 @@ export function ConnectSecretRoute(handle: Handle) { return } try { - session = await readSessionToken() - if (!session) return - const existing = await listExistingSecret(params, session) + const nextSession = await readSessionToken() + if (version !== initVersion || !nextSession) return + session = nextSession + const existing = await listExistingSecret(params, nextSession) + if (version !== initVersion) return if (existing) { setState({ step: 'update-confirm', @@ -275,6 +363,7 @@ export function ConnectSecretRoute(handle: Handle) { } setState({ step: 'input' }) } catch (error) { + if (version !== initVersion) return setState({ step: 'error', error: error instanceof Error ? error.message : 'Unable to load secrets.', @@ -283,6 +372,7 @@ export function ConnectSecretRoute(handle: Handle) { } async function handleSave() { + if (state.step === 'saving') return if (!session) { setState({ step: 'error', @@ -301,20 +391,50 @@ export function ConnectSecretRoute(handle: Handle) { setState({ step: 'saving', error: '' }) try { await saveSecretValue(params, session, state.secretValue) + } catch (error) { + setState({ + step: 'error', + error: error instanceof Error ? error.message : 'Unable to save secret.', + }) + return + } + try { await updateConnectorConfig(params, session) setState({ step: 'success' }) } catch (error) { + if (params.connector) { + try { + await rollbackSecretValue(params, session) + } catch (rollbackError) { + const rollbackMessage = + rollbackError instanceof Error + ? rollbackError.message + : 'Unable to rollback secret.' + const originalMessage = + error instanceof Error + ? error.message + : 'Unable to update connector config.' + setState({ + step: 'error', + error: `Connector configuration failed and rollback did not complete. ${originalMessage} ${rollbackMessage}`, + }) + return + } + } + const message = + error instanceof Error + ? error.message + : 'Unable to update connector config.' setState({ step: 'error', - error: error instanceof Error ? error.message : 'Unable to save secret.', + error: `Connector configuration failed and the secret was rolled back. ${message}`, }) } } function handleErrorBack() { if (!session) { - setState({ step: 'loading', error: '' }) - handle.queueTask(() => initialize()) + startInitialization() return } setState({ step: 'input', error: '' }) @@ -324,7 +444,7 @@ export function ConnectSecretRoute(handle: Handle) { const currentSearch = typeof window === 'undefined' ? '' : window.location.search if (currentSearch !== lastSearch) { lastSearch = currentSearch - handle.queueTask(() => initialize()) + startInitialization() } const params = parseConnectSecretParams() @@ -605,6 +725,7 @@ export function ConnectSecretRoute(handle: Handle) {