From f707e3f3f398eae1938a826ed0578b5aaef6b84e Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Tue, 28 Apr 2026 14:34:19 +0000
Subject: [PATCH 1/3] Remove secret approval request tokens
Co-authored-by: Kent C. Dodds
---
e2e/account-secrets.spec.ts | 85 +-----
.../client/routes/account-approval-shared.ts | 8 +-
.../worker/client/routes/account-secrets.tsx | 6 +-
packages/worker/client/routes/account.tsx | 2 +-
.../app/handlers/account-secrets.node.test.ts | 266 ++++++++----------
.../src/app/handlers/account-secrets.ts | 140 ++++-----
packages/worker/src/mcp/executor.node.test.ts | 10 +-
.../worker/src/mcp/fetch-gateway.node.test.ts | 2 +-
packages/worker/src/mcp/fetch-gateway.ts | 15 +-
.../src/mcp/secrets/errors.node.test.ts | 4 +-
.../mcp/secrets/host-approval.node.test.ts | 101 -------
.../worker/src/mcp/secrets/host-approval.ts | 88 ------
.../src/mcp/secrets/package-approval-url.ts | 4 -
.../src/mcp/secrets/package-approval.ts | 117 --------
14 files changed, 196 insertions(+), 652 deletions(-)
delete mode 100644 packages/worker/src/mcp/secrets/host-approval.node.test.ts
delete mode 100644 packages/worker/src/mcp/secrets/package-approval.ts
diff --git a/e2e/account-secrets.spec.ts b/e2e/account-secrets.spec.ts
index 45f521b582..d4dff3abd8 100644
--- a/e2e/account-secrets.spec.ts
+++ b/e2e/account-secrets.spec.ts
@@ -1,8 +1,5 @@
import { expect, test } from './playwright-utils.ts'
-const e2eCookieSecret = 'LOCAL_AND_PREVIEW_COOKIE_SECRET_32_CHARS_MINIMUM'
-const secretHostApprovalPurpose = 'secret-host-approval'
-
async function saveSecret(
page: Parameters[1]>[0]['page'],
input: {
@@ -29,75 +26,6 @@ async function saveSecret(
expect(response.ok()).toBeTruthy()
}
-function bytesToBase64Url(bytes: Uint8Array) {
- let binary = ''
- for (const value of bytes) {
- binary += String.fromCharCode(value)
- }
- return btoa(binary)
- .replaceAll('+', '-')
- .replaceAll('/', '_')
- .replaceAll('=', '')
-}
-
-async function deriveEncryptionKey(cookieSecret: string, purpose: string) {
- const digest = await crypto.subtle.digest(
- 'SHA-256',
- new TextEncoder().encode(`${purpose}:${cookieSecret}`),
- )
- return crypto.subtle.importKey('raw', digest, 'AES-GCM', false, [
- 'encrypt',
- 'decrypt',
- ])
-}
-
-async function encryptStringWithPurpose(purpose: string, value: string) {
- const key = await deriveEncryptionKey(e2eCookieSecret, purpose)
- const iv = crypto.getRandomValues(new Uint8Array(12))
- const ciphertext = await crypto.subtle.encrypt(
- {
- name: 'AES-GCM',
- iv,
- },
- key,
- new TextEncoder().encode(value),
- )
- return `${bytesToBase64Url(iv)}.${bytesToBase64Url(
- new Uint8Array(ciphertext),
- )}`
-}
-
-async function createHostApprovalToken(input: {
- userId: string
- name: string
- requestedHost: string
-}) {
- const now = Date.now()
- const token = await encryptStringWithPurpose(
- secretHostApprovalPurpose,
- JSON.stringify({
- kind: 'host',
- userId: input.userId,
- name: input.name,
- scope: 'user',
- requestedHost: input.requestedHost,
- storageContext: null,
- iat: now,
- exp: now + 60_000,
- }),
- )
- return `host:${token}`
-}
-
-async function createStableUserIdFromEmail(email: string) {
- const normalized = email.trim().toLowerCase()
- const data = new TextEncoder().encode(normalized)
- const hash = await crypto.subtle.digest('SHA-256', data)
- return Array.from(new Uint8Array(hash), (byte) =>
- byte.toString(16).padStart(2, '0'),
- ).join('')
-}
-
test('switching secrets updates detail view without a full reload', async ({
page,
login,
@@ -172,7 +100,7 @@ test('landing on an approval link shows already added when the host is present',
await saveSecret(page, secret)
await page.goto(
- `/account/secrets/user/${secret.name}?allowed-host=api.cloudflare.com&request=stale-token`,
+ `/account/secrets/user/${secret.name}?allowed-host=api.cloudflare.com`,
)
await expect(
@@ -188,7 +116,7 @@ test('generated host approval link shows one-click approve and persists host', a
page,
login,
}) => {
- const user = await login()
+ await login()
const nonce = Date.now().toString(36)
const secret = {
name: `fly-token-${nonce}`,
@@ -198,15 +126,8 @@ test('generated host approval link shows one-click approve and persists host', a
await saveSecret(page, secret)
const requestedHost = 'api.fly.io'
- const token = await createHostApprovalToken({
- userId: await createStableUserIdFromEmail(user.email),
- name: secret.name,
- requestedHost,
- })
await page.goto(
- `/account/secrets/user/${secret.name}?allowed-host=${requestedHost}&request=${encodeURIComponent(
- token,
- )}`,
+ `/account/secrets/user/${secret.name}?allowed-host=${requestedHost}`,
)
const approvalCard = page.getByRole('heading', {
diff --git a/packages/worker/client/routes/account-approval-shared.ts b/packages/worker/client/routes/account-approval-shared.ts
index 94c8a78a98..cda210ec32 100644
--- a/packages/worker/client/routes/account-approval-shared.ts
+++ b/packages/worker/client/routes/account-approval-shared.ts
@@ -3,7 +3,6 @@ export type ApprovalAction = 'approve' | 'reject'
export type ApprovalScope = 'session' | 'app' | 'user'
export type ApprovalView = {
- token: string
name: string
scope: ApprovalScope
requestedHost: string
@@ -27,11 +26,7 @@ export async function readJson(response: Response) {
export async function submitApprovalRequest<
T extends { ok?: boolean; error?: string },
->(
- action: ApprovalAction,
- requestToken: string,
- requestUrl = accountSecretsApiPath,
-) {
+>(action: ApprovalAction, requestUrl = accountSecretsApiPath) {
const response = await fetch(requestUrl, {
method: 'POST',
headers: {
@@ -41,7 +36,6 @@ export async function submitApprovalRequest<
credentials: 'include',
body: JSON.stringify({
action,
- requestToken,
}),
})
if (response.status === 401) {
diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx
index b36b87a490..a567eaca12 100644
--- a/packages/worker/client/routes/account-secrets.tsx
+++ b/packages/worker/client/routes/account-secrets.tsx
@@ -381,11 +381,10 @@ function replaceSecretsLocation(to: string) {
function getDataRefreshKey(href: string) {
const url = new URL(href, 'http://localhost')
- const request = url.searchParams.get('request') ?? ''
const requestedHost = url.searchParams.get('allowed-host') ?? ''
const requestedCapability = url.searchParams.get('capability') ?? ''
const requestedPackageId = url.searchParams.get('package_id') ?? ''
- return `${url.pathname}?request=${request}&allowed-host=${requestedHost}&capability=${requestedCapability}&package_id=${requestedPackageId}`
+ return `${url.pathname}?allowed-host=${requestedHost}&capability=${requestedCapability}&package_id=${requestedPackageId}`
}
function readFilterState(
@@ -635,7 +634,7 @@ export function AccountSecretsRoute(handle: Handle) {
}
const payload = await submitApprovalRequest<
AccountSecretsPayload & { error?: string; ok?: boolean }
- >(action, approval.token, requestUrl.toString())
+ >(action, requestUrl.toString())
if (!payload) return
applyPayload(
@@ -665,7 +664,6 @@ export function AccountSecretsRoute(handle: Handle) {
)
: buildHrefWithUpdatedFilters({}, { pathname: secretsBasePath })
const nextUrl = new URL(nextHref, window.location.href)
- nextUrl.searchParams.delete('request')
nextUrl.searchParams.delete('allowed-host')
nextUrl.searchParams.delete('capability')
nextUrl.searchParams.delete('package_id')
diff --git a/packages/worker/client/routes/account.tsx b/packages/worker/client/routes/account.tsx
index 15f39f6eb8..a6c0973a28 100644
--- a/packages/worker/client/routes/account.tsx
+++ b/packages/worker/client/routes/account.tsx
@@ -76,7 +76,7 @@ export function AccountRoute(handle: Handle) {
try {
const payload = await submitApprovalRequest<
AccountSecretsPayload & { error?: string; ok?: boolean }
- >(action, approval.token)
+ >(action)
if (!payload) return
email = payload.email
approval = payload.approval
diff --git a/packages/worker/src/app/handlers/account-secrets.node.test.ts b/packages/worker/src/app/handlers/account-secrets.node.test.ts
index caaddee056..3d31e7693b 100644
--- a/packages/worker/src/app/handlers/account-secrets.node.test.ts
+++ b/packages/worker/src/app/handlers/account-secrets.node.test.ts
@@ -28,14 +28,9 @@ const mockModule = vi.hoisted(() => ({
})),
setSecretAllowedHosts: vi.fn(async () => undefined),
saveValue: vi.fn(async () => undefined),
- createSecretHostApprovalToken: vi.fn(
- async (_env: unknown, input: { name: string; requestedHost: string }) => {
- return `token:${input.name}:${input.requestedHost}`
- },
- ),
buildSecretHostApprovalUrl: vi.fn(
- (input: { name: string; requestedHost: string; token: string }) =>
- `https://example.com/account/secrets/user/${input.name}?allowed-host=${input.requestedHost}&request=${input.token}`,
+ (input: { name: string; requestedHost: string }) =>
+ `https://example.com/account/secrets/user/${input.name}?allowed-host=${input.requestedHost}`,
),
listSavedPackagesByUserId: vi.fn(async () => []),
listSecrets: vi.fn(async () => []),
@@ -45,12 +40,6 @@ const mockModule = vi.hoisted(() => ({
setSecretAllowedCapabilities: vi.fn(async () => undefined),
setSecretAllowedPackages: vi.fn(async () => undefined),
getValue: vi.fn(async () => null),
- verifySecretHostApprovalToken: vi.fn(async () => {
- throw new Error('not used')
- }),
- verifySecretPackageApprovalToken: vi.fn(async () => {
- throw new Error('not used')
- }),
}))
vi.mock('#app/authenticated-user.ts', () => ({
@@ -91,19 +80,8 @@ vi.mock('#mcp/secrets/allowed-capabilities.ts', () => ({
}))
vi.mock('#mcp/secrets/host-approval.ts', () => ({
- createSecretHostApprovalToken: (...args: Array) =>
- mockModule.createSecretHostApprovalToken(...args),
buildSecretHostApprovalUrl: (...args: Array) =>
mockModule.buildSecretHostApprovalUrl(...args),
- verifySecretHostApprovalToken: (...args: Array) =>
- mockModule.verifySecretHostApprovalToken(...args),
- secretHostApprovalTokenPrefix: 'host:',
-}))
-
-vi.mock('#mcp/secrets/package-approval.ts', () => ({
- verifySecretPackageApprovalToken: (...args: Array) =>
- mockModule.verifySecretPackageApprovalToken(...args),
- secretPackageApprovalTokenPrefix: 'pkg:',
}))
vi.mock('#mcp/secrets/service.ts', () => ({
@@ -176,52 +154,30 @@ test('connect oauth returns direct host approval links for saved token secrets',
secretName: 'githubAccessToken',
host: 'api.github.com',
approvalUrl:
- 'https://example.com/account/secrets/user/githubAccessToken?allowed-host=api.github.com&request=token:githubAccessToken:api.github.com',
+ 'https://example.com/account/secrets/user/githubAccessToken?allowed-host=api.github.com',
},
{
secretName: 'githubAccessToken',
host: 'github.com',
approvalUrl:
- 'https://example.com/account/secrets/user/githubAccessToken?allowed-host=github.com&request=token:githubAccessToken:github.com',
+ 'https://example.com/account/secrets/user/githubAccessToken?allowed-host=github.com',
},
{
secretName: 'githubRefreshToken',
host: 'api.github.com',
approvalUrl:
- 'https://example.com/account/secrets/user/githubRefreshToken?allowed-host=api.github.com&request=token:githubRefreshToken:api.github.com',
+ 'https://example.com/account/secrets/user/githubRefreshToken?allowed-host=api.github.com',
},
{
secretName: 'githubRefreshToken',
host: 'github.com',
approvalUrl:
- 'https://example.com/account/secrets/user/githubRefreshToken?allowed-host=github.com&request=token:githubRefreshToken:github.com',
+ 'https://example.com/account/secrets/user/githubRefreshToken?allowed-host=github.com',
},
],
connectorName: 'GitHub',
})
- expect(mockModule.createSecretHostApprovalToken).toHaveBeenCalledTimes(4)
- expect(mockModule.createSecretHostApprovalToken).toHaveBeenNthCalledWith(
- 1,
- expect.objectContaining({ COOKIE_SECRET: 'secret' }),
- expect.objectContaining({
- userId: 'stable-user-1',
- name: 'githubAccessToken',
- scope: 'user',
- requestedHost: 'api.github.com',
- storageContext: null,
- }),
- )
- expect(mockModule.createSecretHostApprovalToken).toHaveBeenNthCalledWith(
- 4,
- expect.objectContaining({ COOKIE_SECRET: 'secret' }),
- expect.objectContaining({
- userId: 'stable-user-1',
- name: 'githubRefreshToken',
- scope: 'user',
- requestedHost: 'github.com',
- storageContext: null,
- }),
- )
+ expect(mockModule.buildSecretHostApprovalUrl).toHaveBeenCalledTimes(4)
expect(mockModule.setSecretAllowedHosts).not.toHaveBeenCalled()
})
@@ -302,7 +258,107 @@ test('connect oauth omits direct host approval links when hosts are already appr
'fleet-auth.prd.vn.cloud.tesla.com',
]),
)
- expect(mockModule.createSecretHostApprovalToken).not.toHaveBeenCalled()
+ expect(mockModule.buildSecretHostApprovalUrl).not.toHaveBeenCalled()
+})
+
+test('host approval view is derived from allowed-host and selected secret', async () => {
+ mockModule.listSecrets.mockResolvedValueOnce([
+ {
+ name: 'cloudflareToken',
+ scope: 'user',
+ description: 'Cloudflare token',
+ appId: null,
+ allowedHosts: [],
+ allowedCapabilities: [],
+ allowedPackages: [],
+ createdAt: new Date(0).toISOString(),
+ updatedAt: new Date(0).toISOString(),
+ ttlMs: null,
+ },
+ ])
+ mockModule.resolveSecret.mockResolvedValueOnce({
+ found: true,
+ value: 'secret-value',
+ })
+ mockModule.listSecrets.mockResolvedValueOnce([
+ {
+ name: 'cloudflareToken',
+ scope: 'user',
+ description: 'Cloudflare token',
+ appId: null,
+ allowedHosts: [],
+ allowedCapabilities: [],
+ allowedPackages: [],
+ createdAt: new Date(0).toISOString(),
+ updatedAt: new Date(0).toISOString(),
+ ttlMs: null,
+ },
+ ])
+
+ const handler = createAccountSecretsApiHandler(createEnv())
+ const response = await handler.action({
+ request: new Request(
+ 'https://example.com/account/secrets.json?selected=user::::cloudflareToken&allowed-host=API.Cloudflare.com',
+ { method: 'GET' },
+ ),
+ params: {},
+ } as never)
+
+ expect(response.status).toBe(200)
+ await expect(response.json()).resolves.toMatchObject({
+ ok: true,
+ approval: {
+ name: 'cloudflareToken',
+ scope: 'user',
+ requestedHost: 'api.cloudflare.com',
+ requestedPackageId: null,
+ currentAllowedHosts: [],
+ },
+ })
+})
+
+test('host approval approve persists host from allowed-host and selected secret', async () => {
+ mockModule.listSecrets.mockResolvedValueOnce([
+ {
+ name: 'cloudflareToken',
+ scope: 'user',
+ description: 'Cloudflare token',
+ appId: null,
+ allowedHosts: ['api.github.com'],
+ allowedCapabilities: [],
+ allowedPackages: [],
+ createdAt: new Date(0).toISOString(),
+ updatedAt: new Date(0).toISOString(),
+ ttlMs: null,
+ },
+ ])
+ mockModule.listSecrets.mockResolvedValueOnce([])
+ mockModule.listSavedPackagesByUserId.mockResolvedValueOnce([])
+ mockModule.listAppSecretsByAppIds.mockResolvedValueOnce(new Map())
+
+ const handler = createAccountSecretsApiHandler(createEnv())
+ const response = await handler.action({
+ request: new Request(
+ 'https://example.com/account/secrets.json?selected=user::::cloudflareToken&allowed-host=API.Cloudflare.com',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'approve' }),
+ },
+ ),
+ params: {},
+ } as never)
+
+ expect(response.status).toBe(200)
+ await expect(response.json()).resolves.toMatchObject({ ok: true })
+ expect(mockModule.setSecretAllowedHosts).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'cloudflareToken',
+ scope: 'user',
+ allowedHosts: ['api.github.com', 'api.cloudflare.com'],
+ storageContext: { appId: null, sessionId: null },
+ }),
+ )
})
test('account secrets payload preserves app titles and allowed packages', async () => {
@@ -396,84 +452,21 @@ test('account secrets payload preserves app titles and allowed packages', async
})
})
-test('package approval expiry surfaces package-token error without host fallback', async () => {
- mockModule.verifySecretPackageApprovalToken.mockRejectedValueOnce(
- new Error('Secret package approval request has expired.'),
- )
-
- const handler = createAccountSecretsApiHandler(createEnv())
- const response = await handler.action({
- request: new Request('https://example.com/account/secrets.json', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- action: 'approve',
- requestToken: 'expired-package-token',
- }),
- }),
- params: {},
- } as never)
-
- expect(response.status).toBe(400)
- await expect(response.json()).resolves.toMatchObject({
- ok: false,
- error: 'Secret package approval request has expired.',
- })
- expect(mockModule.verifySecretHostApprovalToken).not.toHaveBeenCalled()
-})
-
-test('malformed package approval token surfaces package-token error without host fallback', async () => {
- mockModule.verifySecretPackageApprovalToken.mockRejectedValueOnce(
- new Error('Invalid secret package approval request.'),
- )
-
- const handler = createAccountSecretsApiHandler(createEnv())
- const response = await handler.action({
- request: new Request('https://example.com/account/secrets.json', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- action: 'approve',
- requestToken: 'pkg:invalid-package-token',
- }),
- }),
- params: {},
- } as never)
-
- expect(response.status).toBe(400)
- await expect(response.json()).resolves.toMatchObject({
- ok: false,
- error: 'Invalid secret package approval request.',
- })
- expect(mockModule.verifySecretHostApprovalToken).not.toHaveBeenCalled()
-})
-
test('package approval reject succeeds even when the secret no longer exists', async () => {
- mockModule.verifySecretPackageApprovalToken.mockResolvedValueOnce({
- kind: 'package',
- userId: 'stable-user-1',
- name: 'discordBotToken',
- scope: 'user',
- packageId: 'pkg-allowed',
- packageKodyId: 'discord-gateway',
- storageContext: null,
- iat: Date.now(),
- exp: Date.now() + 60_000,
- })
mockModule.listSavedPackagesByUserId.mockResolvedValueOnce([])
mockModule.listSecrets.mockResolvedValueOnce([])
mockModule.listAppSecretsByAppIds.mockResolvedValueOnce(new Map())
const handler = createAccountSecretsApiHandler(createEnv())
const response = await handler.action({
- request: new Request('https://example.com/account/secrets.json', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- action: 'reject',
- requestToken: 'package-token',
- }),
- }),
+ request: new Request(
+ 'https://example.com/account/secrets.json?selected=user::::discordBotToken&package_id=pkg-allowed',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'reject' }),
+ },
+ ),
params: {},
} as never)
@@ -487,17 +480,6 @@ test('package approval reject succeeds even when the secret no longer exists', a
})
test('package approval approve deduplicates allowed package ids', async () => {
- mockModule.verifySecretPackageApprovalToken.mockResolvedValueOnce({
- kind: 'package',
- userId: 'stable-user-1',
- name: 'discordBotToken',
- scope: 'user',
- packageId: 'pkg-new',
- packageKodyId: 'discord-gateway',
- storageContext: null,
- iat: Date.now(),
- exp: Date.now() + 60_000,
- })
mockModule.listSecrets.mockResolvedValueOnce([
{
name: 'discordBotToken',
@@ -518,14 +500,14 @@ test('package approval approve deduplicates allowed package ids', async () => {
const handler = createAccountSecretsApiHandler(createEnv())
const response = await handler.action({
- request: new Request('https://example.com/account/secrets.json', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- action: 'approve',
- requestToken: 'package-token',
- }),
- }),
+ request: new Request(
+ 'https://example.com/account/secrets.json?selected=user::::discordBotToken&package_id=pkg-new',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'approve' }),
+ },
+ ),
params: {},
} as never)
diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts
index 7036b3c165..4d5be39d8f 100644
--- a/packages/worker/src/app/handlers/account-secrets.ts
+++ b/packages/worker/src/app/handlers/account-secrets.ts
@@ -10,16 +10,7 @@ import { redirectToLogin } from '#app/auth-redirect.ts'
import { Layout } from '#app/layout.ts'
import { render } from '#app/render.ts'
import { type StorageContext } from '#mcp/storage.ts'
-import {
- buildSecretHostApprovalUrl,
- createSecretHostApprovalToken,
- secretHostApprovalTokenPrefix,
- verifySecretHostApprovalToken,
-} from '#mcp/secrets/host-approval.ts'
-import {
- secretPackageApprovalTokenPrefix,
- verifySecretPackageApprovalToken,
-} from '#mcp/secrets/package-approval.ts'
+import { buildSecretHostApprovalUrl } from '#mcp/secrets/host-approval.ts'
import {
deleteSecret,
listAppSecretsByAppIds,
@@ -78,7 +69,6 @@ type AccountSecretDetail = AccountSecretListItem & {
}
type SecretApprovalView = {
- token: string
name: string
scope: SecretScope
requestedHost: string
@@ -170,7 +160,6 @@ export function createAccountSecretsApiHandler(env: Env) {
env,
user,
action,
- body,
})
}
if (action === 'save') {
@@ -457,19 +446,11 @@ async function buildConnectOauthHostApprovalLinks(input: {
if (approvedHostsBySecretName.get(secretName)?.has(host)) {
return null
}
- const token = await createSecretHostApprovalToken(input.env, {
- userId: input.userId,
- name: secretName,
- scope: 'user',
- requestedHost: host,
- storageContext: null,
- })
return {
secretName,
host,
approvalUrl: buildSecretHostApprovalUrl({
baseUrl,
- token,
name: secretName,
scope: 'user',
requestedHost: host,
@@ -638,7 +619,6 @@ async function buildAccountSecretsPayload(input: {
savedPackages?: Array
}): Promise {
const url = new URL(input.request.url)
- const approvalToken = url.searchParams.get('request')
const requestedApprovalHost = readApprovalHost(url)
const requestedCapability = readRequestedCapability(url)
const requestedPackageId = readRequestedPackageId(url)
@@ -665,12 +645,12 @@ async function buildAccountSecretsPayload(input: {
let approval: SecretApprovalView | null = null
let approvalError: string | null = null
- if (approvalToken) {
+ if (input.selectedSecretId && (requestedApprovalHost || requestedPackageId)) {
try {
approval = await resolveSecretApprovalView({
env: input.env,
userId: input.user.mcpUser.userId,
- token: approvalToken,
+ secretId: input.selectedSecretId,
requestedHost: requestedApprovalHost,
requestedCapability,
requestedPackageId,
@@ -679,7 +659,7 @@ async function buildAccountSecretsPayload(input: {
approvalError =
error instanceof Error
? error.message
- : 'Unable to verify approval request.'
+ : 'Unable to read approval request.'
}
}
@@ -733,62 +713,65 @@ async function listAccountSecrets(input: {
}
type ResolvedSecretApproval =
- | Awaited>
- | Awaited>
-
-function isExpiredPackageApprovalError(error: unknown) {
- return (
- error instanceof Error &&
- error.message === 'Secret package approval request has expired.'
- )
-}
-
-async function resolveApprovalRequest(
- env: Env,
- token: string,
-): Promise {
- if (token.startsWith(secretHostApprovalTokenPrefix)) {
- return await verifySecretHostApprovalToken(env, token)
+ | {
+ kind: 'host'
+ name: string
+ scope: SecretScope
+ requestedHost: string
+ storageContext: StorageContext | null
+ }
+ | {
+ kind: 'package'
+ name: string
+ scope: SecretScope
+ packageId: string
+ storageContext: StorageContext | null
+ }
+
+function resolveApprovalRequest(input: {
+ secretId: string | null
+ requestedHost: string | null
+ requestedPackageId: string | null
+}): ResolvedSecretApproval {
+ const parsed = input.secretId ? parseAccountSecretId(input.secretId) : null
+ if (!parsed) {
+ throw new Error('Invalid approval request.')
}
- if (token.startsWith(secretPackageApprovalTokenPrefix)) {
- return await verifySecretPackageApprovalToken(env, token)
+ const storageContext = getSecretContextForAccountSecret(parsed)
+ if (input.requestedPackageId) {
+ return {
+ kind: 'package',
+ name: parsed.name,
+ scope: parsed.scope,
+ packageId: input.requestedPackageId,
+ storageContext,
+ }
}
- try {
- return await verifySecretPackageApprovalToken(env, token)
- } catch (error) {
- if (isExpiredPackageApprovalError(error)) {
- throw error
+ if (input.requestedHost) {
+ const [requestedHost] = normalizeAllowedHosts([input.requestedHost])
+ if (!requestedHost) {
+ throw new Error('Invalid approval request host.')
+ }
+ return {
+ kind: 'host',
+ name: parsed.name,
+ scope: parsed.scope,
+ requestedHost,
+ storageContext,
}
}
- return await verifySecretHostApprovalToken(env, token)
+ throw new Error('Approval request is missing a host or package.')
}
async function resolveSecretApprovalView(input: {
env: Env
userId: string
- token: string
+ secretId: string
requestedHost: string | null
requestedCapability: string | null
requestedPackageId: string | null
}) {
- const approval = await resolveApprovalRequest(input.env, input.token)
- if (approval.userId !== input.userId) {
- throw new Error('Approval request mismatch.')
- }
- if (
- approval.kind === 'host' &&
- input.requestedHost != null &&
- approval.requestedHost !== normalizeAllowedHosts([input.requestedHost])[0]
- ) {
- throw new Error('Approval request host mismatch.')
- }
- if (
- approval.kind === 'package' &&
- input.requestedPackageId != null &&
- approval.packageId !== input.requestedPackageId
- ) {
- throw new Error('Approval request package mismatch.')
- }
+ const approval = resolveApprovalRequest(input)
const secrets = await listSecrets({
env: input.env,
userId: input.userId,
@@ -802,7 +785,6 @@ async function resolveSecretApprovalView(input: {
throw new Error('Secret not found.')
}
return {
- token: input.token,
name: approval.name,
scope: approval.scope,
requestedHost: approval.kind === 'host' ? approval.requestedHost : '',
@@ -927,24 +909,14 @@ async function handleApprovalAction(input: {
env: Env
user: NonNullable>>
action: SecretApprovalAction
- body: object
}) {
- const token = readString(input.body, 'requestToken')
- if (!token) {
- return jsonResponse(
- { ok: false, error: 'Approval request token is required.' },
- 400,
- )
- }
-
try {
- const approval = await resolveApprovalRequest(input.env, token)
- if (approval.userId !== input.user.mcpUser.userId) {
- return jsonResponse(
- { ok: false, error: 'Approval request mismatch.' },
- 403,
- )
- }
+ const url = new URL(input.request.url)
+ const approval = resolveApprovalRequest({
+ secretId: readSelectedSecretId(input.request),
+ requestedHost: readApprovalHost(url),
+ requestedPackageId: readRequestedPackageId(url),
+ })
if (approval.kind === 'package') {
if (input.action === 'approve') {
diff --git a/packages/worker/src/mcp/executor.node.test.ts b/packages/worker/src/mcp/executor.node.test.ts
index deed9bc62e..7677c85745 100644
--- a/packages/worker/src/mcp/executor.node.test.ts
+++ b/packages/worker/src/mcp/executor.node.test.ts
@@ -143,13 +143,13 @@ test('getExecutionErrorDetails returns batch host approvals', () => {
secretName: 'cloudflareToken',
host: 'api.cloudflare.com',
approvalUrl:
- 'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token',
+ 'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com',
},
{
secretName: 'slackToken',
host: 'slack.com',
approvalUrl:
- 'https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token',
+ 'https://example.com/account/secrets/user/slackToken?allowed-host=slack.com',
},
]),
)
@@ -157,7 +157,7 @@ test('getExecutionErrorDetails returns batch host approvals', () => {
expect(getExecutionErrorDetails(error)).toEqual({
kind: 'host_approval_required_batch',
message:
- 'Secrets require host approval: [{"secretName":"cloudflareToken","host":"api.cloudflare.com","approvalUrl":"https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token"},{"secretName":"slackToken","host":"slack.com","approvalUrl":"https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token"}]',
+ 'Secrets require host approval: [{"secretName":"cloudflareToken","host":"api.cloudflare.com","approvalUrl":"https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com"},{"secretName":"slackToken","host":"slack.com","approvalUrl":"https://example.com/account/secrets/user/slackToken?allowed-host=slack.com"}]',
nextStep:
'Ask the user whether they want to approve these hosts for the listed secrets in the account web UI, then retry after approval.',
missingApprovals: [
@@ -165,13 +165,13 @@ test('getExecutionErrorDetails returns batch host approvals', () => {
secretName: 'cloudflareToken',
host: 'api.cloudflare.com',
approvalUrl:
- 'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token',
+ 'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com',
},
{
secretName: 'slackToken',
host: 'slack.com',
approvalUrl:
- 'https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token',
+ 'https://example.com/account/secrets/user/slackToken?allowed-host=slack.com',
},
],
suggestedAction: {
diff --git a/packages/worker/src/mcp/fetch-gateway.node.test.ts b/packages/worker/src/mcp/fetch-gateway.node.test.ts
index f93e422dec..bb11d86227 100644
--- a/packages/worker/src/mcp/fetch-gateway.node.test.ts
+++ b/packages/worker/src/mcp/fetch-gateway.node.test.ts
@@ -46,7 +46,7 @@ test('fetch gateway blocks placeholders when allowed hosts are empty', async ()
secretName: 'spotifyRefreshToken',
host: 'example.com',
approvalUrl: expect.stringMatching(
- /\/account\/secrets\/user\/spotifyRefreshToken\?[^#]*allowed-host=example\.com[^#]*request=/,
+ /\/account\/secrets\/user\/spotifyRefreshToken\?allowed-host=example\.com$/,
),
}),
])
diff --git a/packages/worker/src/mcp/fetch-gateway.ts b/packages/worker/src/mcp/fetch-gateway.ts
index fa5052a36d..d08ba0a7ee 100644
--- a/packages/worker/src/mcp/fetch-gateway.ts
+++ b/packages/worker/src/mcp/fetch-gateway.ts
@@ -1,8 +1,5 @@
import { WorkerEntrypoint } from 'cloudflare:workers'
-import {
- buildSecretHostApprovalUrl,
- createSecretHostApprovalToken,
-} from '#mcp/secrets/host-approval.ts'
+import { buildSecretHostApprovalUrl } from '#mcp/secrets/host-approval.ts'
import {
buildSecretPlaceholder,
parseSecretPlaceholders,
@@ -99,7 +96,6 @@ export async function expandSecretPlaceholders(input: {
}
const normalizedHost = normalizeHost(requestedHost)
const missingApprovals = await collectHostApprovalEntries({
- env: input.env,
props: input.props,
requestedHost,
normalizedHost,
@@ -167,7 +163,6 @@ function resolveRequestUrlForFetchGateway(url: string, baseUrl: string) {
}
async function collectHostApprovalEntries(input: {
- env: Pick
props: FetchGatewayProps
requestedHost: string
normalizedHost: string
@@ -182,16 +177,8 @@ async function collectHostApprovalEntries(input: {
resolved.allowedHosts.length > 0 &&
resolved.allowedHosts.includes(input.normalizedHost)
if (allowedForHost) return null
- const approvalToken = await createSecretHostApprovalToken(input.env, {
- userId: input.props.userId!,
- name: referenced.name,
- scope: resolved.scope ?? referenced.scope ?? 'user',
- requestedHost: input.requestedHost,
- storageContext: input.props.storageContext,
- })
const approvalUrl = buildSecretHostApprovalUrl({
baseUrl: input.props.baseUrl,
- token: approvalToken,
name: referenced.name,
scope: resolved.scope ?? referenced.scope ?? 'user',
requestedHost: input.requestedHost,
diff --git a/packages/worker/src/mcp/secrets/errors.node.test.ts b/packages/worker/src/mcp/secrets/errors.node.test.ts
index faaba35bdb..112856a353 100644
--- a/packages/worker/src/mcp/secrets/errors.node.test.ts
+++ b/packages/worker/src/mcp/secrets/errors.node.test.ts
@@ -92,13 +92,13 @@ test('batch approval parsers round-trip entries and reject invalid payloads', ()
secretName: 'cloudflareToken',
host: 'api.cloudflare.com',
approvalUrl:
- 'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token',
+ 'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com',
},
{
secretName: 'githubToken',
host: 'api.github.com',
approvalUrl:
- 'https://example.com/account/secrets/user/githubToken?allowed-host=api.github.com&request=token',
+ 'https://example.com/account/secrets/user/githubToken?allowed-host=api.github.com',
},
]
expect(
diff --git a/packages/worker/src/mcp/secrets/host-approval.node.test.ts b/packages/worker/src/mcp/secrets/host-approval.node.test.ts
deleted file mode 100644
index 052cd0f2df..0000000000
--- a/packages/worker/src/mcp/secrets/host-approval.node.test.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { expect, test } from 'vitest'
-import {
- createSecretHostApprovalToken,
- verifySecretHostApprovalToken,
-} from './host-approval.ts'
-import { encryptStringWithPurpose } from './crypto.ts'
-
-const env = {
- COOKIE_SECRET: 'test-cookie-secret',
-} as Pick
-
-test('verifySecretHostApprovalToken accepts legacy tokens without kind', async () => {
- const now = Date.now()
- const token = await encryptStringWithPurpose(
- env,
- 'secret-host-approval',
- JSON.stringify({
- userId: 'user-1',
- name: 'cloudflareToken',
- scope: 'user',
- requestedHost: 'API.Cloudflare.com',
- storageContext: null,
- iat: now,
- exp: now + 60_000,
- }),
- )
-
- await expect(verifySecretHostApprovalToken(env, token)).resolves.toEqual({
- kind: 'host',
- userId: 'user-1',
- name: 'cloudflareToken',
- scope: 'user',
- requestedHost: 'api.cloudflare.com',
- storageContext: null,
- iat: now,
- exp: now + 60_000,
- })
-})
-
-test('verifySecretHostApprovalToken still accepts new tokens with kind', async () => {
- const token = await createSecretHostApprovalToken(env, {
- userId: 'user-1',
- name: 'cloudflareToken',
- scope: 'user',
- requestedHost: 'api.cloudflare.com',
- storageContext: null,
- })
-
- await expect(verifySecretHostApprovalToken(env, token)).resolves.toMatchObject({
- kind: 'host',
- userId: 'user-1',
- name: 'cloudflareToken',
- scope: 'user',
- requestedHost: 'api.cloudflare.com',
- storageContext: null,
- })
-})
-
-test('verifySecretHostApprovalToken rejects tokens with explicit non-host kind', async () => {
- const now = Date.now()
- const token = await encryptStringWithPurpose(
- env,
- 'secret-host-approval',
- JSON.stringify({
- kind: 'package',
- userId: 'user-1',
- name: 'cloudflareToken',
- scope: 'user',
- requestedHost: 'api.cloudflare.com',
- storageContext: null,
- iat: now,
- exp: now + 60_000,
- }),
- )
-
- await expect(verifySecretHostApprovalToken(env, token)).rejects.toThrow(
- 'Invalid secret host approval request.',
- )
-})
-
-test('verifySecretHostApprovalToken rejects tokens with explicit non-host kind', async () => {
- const now = Date.now()
- const token = await encryptStringWithPurpose(
- env,
- 'secret-host-approval',
- JSON.stringify({
- kind: 'package',
- userId: 'user-1',
- name: 'cloudflareToken',
- scope: 'user',
- requestedHost: 'api.cloudflare.com',
- storageContext: null,
- iat: now,
- exp: now + 60_000,
- }),
- )
-
- await expect(verifySecretHostApprovalToken(env, token)).rejects.toThrow(
- 'Invalid secret host approval request.',
- )
-})
diff --git a/packages/worker/src/mcp/secrets/host-approval.ts b/packages/worker/src/mcp/secrets/host-approval.ts
index 7a0a1a5907..2302108c83 100644
--- a/packages/worker/src/mcp/secrets/host-approval.ts
+++ b/packages/worker/src/mcp/secrets/host-approval.ts
@@ -1,97 +1,10 @@
import { buildAccountSecretPath } from '@kody-internal/shared/account-secret-route.ts'
import { type StorageContext } from '#mcp/storage.ts'
-import { decryptStringWithPurpose, encryptStringWithPurpose } from './crypto.ts'
import { normalizeHost } from './allowed-hosts.ts'
import { type SecretScope } from './types.ts'
-const secretHostApprovalPurpose = 'secret-host-approval'
-const defaultSecretHostApprovalTtlMs = 1000 * 60 * 60 * 24
-export const secretHostApprovalTokenPrefix = 'host:'
-
-export type SecretHostApprovalRequest = {
- kind: 'host'
- userId: string
- name: string
- scope: SecretScope
- requestedHost: string
- storageContext: StorageContext | null
- iat: number
- exp: number
-}
-
-export async function createSecretHostApprovalToken(
- env: Pick,
- input: {
- userId: string
- name: string
- scope: SecretScope
- requestedHost: string
- storageContext: StorageContext | null
- ttlMs?: number
- },
-) {
- const now = Date.now()
- const ttlMs = input.ttlMs ?? defaultSecretHostApprovalTtlMs
- const token = await encryptStringWithPurpose(
- env,
- secretHostApprovalPurpose,
- JSON.stringify({
- kind: 'host',
- userId: input.userId,
- name: input.name.trim(),
- scope: input.scope,
- requestedHost: normalizeHost(input.requestedHost),
- storageContext: input.storageContext ?? null,
- iat: now,
- exp: now + ttlMs,
- } satisfies SecretHostApprovalRequest),
- )
- return `${secretHostApprovalTokenPrefix}${token}`
-}
-
-export async function verifySecretHostApprovalToken(
- env: Pick,
- token: string,
-) {
- if (token.startsWith('pkg:')) {
- throw new Error('Invalid secret host approval request.')
- }
- const encodedToken = token.startsWith(secretHostApprovalTokenPrefix)
- ? token.slice(secretHostApprovalTokenPrefix.length)
- : token
- const raw = await decryptStringWithPurpose(
- env,
- secretHostApprovalPurpose,
- encodedToken,
- )
- const parsed = JSON.parse(raw) as Partial
- if (
- (parsed.kind != null && parsed.kind !== 'host') ||
- typeof parsed.userId !== 'string' ||
- typeof parsed.name !== 'string' ||
- typeof parsed.scope !== 'string' ||
- typeof parsed.requestedHost !== 'string'
- ) {
- throw new Error('Invalid secret host approval request.')
- }
- if (typeof parsed.exp === 'number' && Date.now() > parsed.exp) {
- throw new Error('Secret host approval request has expired.')
- }
- return {
- kind: 'host',
- userId: parsed.userId,
- name: parsed.name.trim(),
- scope: parsed.scope as SecretScope,
- requestedHost: normalizeHost(parsed.requestedHost),
- storageContext: parsed.storageContext ?? null,
- iat: typeof parsed.iat === 'number' ? parsed.iat : Date.now(),
- exp: typeof parsed.exp === 'number' ? parsed.exp : Date.now(),
- } satisfies SecretHostApprovalRequest
-}
-
export function buildSecretHostApprovalUrl(input: {
baseUrl: string
- token: string
name: string
scope: SecretScope
requestedHost: string
@@ -105,6 +18,5 @@ export function buildSecretHostApprovalUrl(input: {
})
const url = new URL(secretPath, input.baseUrl)
url.searchParams.set('allowed-host', normalizeHost(input.requestedHost))
- url.searchParams.set('request', input.token)
return url.toString()
}
diff --git a/packages/worker/src/mcp/secrets/package-approval-url.ts b/packages/worker/src/mcp/secrets/package-approval-url.ts
index 76ff44eead..9d1529b1d4 100644
--- a/packages/worker/src/mcp/secrets/package-approval-url.ts
+++ b/packages/worker/src/mcp/secrets/package-approval-url.ts
@@ -8,7 +8,6 @@ export function buildSecretPackageApprovalUrl(input: {
scope: SecretScope
packageId: string
kodyId: string | null
- token?: string | null
storageContext: StorageContext | null
}) {
if (input.scope === 'app' && !input.storageContext?.appId) {
@@ -30,8 +29,5 @@ export function buildSecretPackageApprovalUrl(input: {
if (input.kodyId) {
url.searchParams.set('package', input.kodyId)
}
- if (input.token) {
- url.searchParams.set('request', input.token)
- }
return url.toString()
}
diff --git a/packages/worker/src/mcp/secrets/package-approval.ts b/packages/worker/src/mcp/secrets/package-approval.ts
deleted file mode 100644
index 1257005672..0000000000
--- a/packages/worker/src/mcp/secrets/package-approval.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { buildAccountSecretPath } from '@kody-internal/shared/account-secret-route.ts'
-import { type StorageContext } from '#mcp/storage.ts'
-import { decryptStringWithPurpose, encryptStringWithPurpose } from './crypto.ts'
-import { type SecretScope } from './types.ts'
-
-const secretPackageApprovalPurpose = 'secret-package-approval'
-const defaultSecretPackageApprovalTtlMs = 1000 * 60 * 60 * 24
-export const secretPackageApprovalTokenPrefix = 'pkg:'
-
-export type SecretPackageApprovalRequest = {
- kind: 'package'
- userId: string
- name: string
- scope: SecretScope
- packageId: string
- packageKodyId: string | null
- storageContext: StorageContext | null
- iat: number
- exp: number
-}
-
-export async function createSecretPackageApprovalToken(
- env: Pick,
- input: {
- userId: string
- name: string
- scope: SecretScope
- packageId: string
- packageKodyId: string | null
- storageContext: StorageContext | null
- ttlMs?: number
- },
-) {
- const now = Date.now()
- const ttlMs = input.ttlMs ?? defaultSecretPackageApprovalTtlMs
- const token = await encryptStringWithPurpose(
- env,
- secretPackageApprovalPurpose,
- JSON.stringify({
- kind: 'package',
- userId: input.userId,
- name: input.name.trim(),
- scope: input.scope,
- packageId: input.packageId.trim(),
- packageKodyId: input.packageKodyId?.trim() || null,
- storageContext: input.storageContext ?? null,
- iat: now,
- exp: now + ttlMs,
- } satisfies SecretPackageApprovalRequest),
- )
- return `${secretPackageApprovalTokenPrefix}${token}`
-}
-
-export async function verifySecretPackageApprovalToken(
- env: Pick,
- token: string,
-) {
- const encryptedToken = token.startsWith(secretPackageApprovalTokenPrefix)
- ? token.slice(secretPackageApprovalTokenPrefix.length)
- : token
- const raw = await decryptStringWithPurpose(
- env,
- secretPackageApprovalPurpose,
- encryptedToken,
- )
- const parsed = JSON.parse(raw) as Partial
- if (
- parsed.kind !== 'package' ||
- typeof parsed.userId !== 'string' ||
- typeof parsed.name !== 'string' ||
- typeof parsed.scope !== 'string' ||
- typeof parsed.packageId !== 'string'
- ) {
- throw new Error('Invalid secret package approval request.')
- }
- if (typeof parsed.exp === 'number' && Date.now() > parsed.exp) {
- throw new Error('Secret package approval request has expired.')
- }
- return {
- kind: 'package',
- userId: parsed.userId,
- name: parsed.name.trim(),
- scope: parsed.scope as SecretScope,
- packageId: parsed.packageId.trim(),
- packageKodyId:
- typeof parsed.packageKodyId === 'string'
- ? (parsed.packageKodyId.trim() || null)
- : null,
- storageContext: parsed.storageContext ?? null,
- iat: typeof parsed.iat === 'number' ? parsed.iat : Date.now(),
- exp: typeof parsed.exp === 'number' ? parsed.exp : Date.now(),
- } satisfies SecretPackageApprovalRequest
-}
-
-export function buildSecretPackageApprovalTokenUrl(input: {
- baseUrl: string
- token: string
- name: string
- scope: SecretScope
- packageId: string
- packageKodyId: string | null
- storageContext: StorageContext | null
-}) {
- const secretPath = buildAccountSecretPath({
- name: input.name,
- scope: input.scope,
- appId: input.storageContext?.appId ?? null,
- sessionId: input.storageContext?.sessionId ?? null,
- })
- const url = new URL(secretPath, input.baseUrl)
- url.searchParams.set('package_id', input.packageId)
- if (input.packageKodyId) {
- url.searchParams.set('package', input.packageKodyId)
- }
- url.searchParams.set('request', input.token)
- return url.toString()
-}
From 7cda239bf30bc76d93a1bca4057ae8b21e18a5dc Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Tue, 28 Apr 2026 15:49:32 +0000
Subject: [PATCH 2/3] Preserve approval query params on submit
Co-authored-by: Kent C. Dodds
---
.../worker/client/routes/account-secrets.tsx | 6 +++--
packages/worker/client/routes/account.tsx | 7 +++++-
.../app/handlers/account-secrets.node.test.ts | 23 +++++++++++++++++++
.../src/app/handlers/account-secrets.ts | 3 +++
4 files changed, 36 insertions(+), 3 deletions(-)
diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx
index a567eaca12..f1527731b9 100644
--- a/packages/worker/client/routes/account-secrets.tsx
+++ b/packages/worker/client/routes/account-secrets.tsx
@@ -627,8 +627,10 @@ export function AccountSecretsRoute(handle: Handle) {
handle.update()
try {
- const selection = getSelectionState(getCurrentHref())
- const requestUrl = new URL(accountSecretsApiPath, getCurrentHref())
+ const currentUrl = new URL(getCurrentHref())
+ const selection = getSelectionState(currentUrl.toString())
+ const requestUrl = new URL(accountSecretsApiPath, currentUrl)
+ requestUrl.search = currentUrl.search
if (selection.selectedSecretId) {
requestUrl.searchParams.set('selected', selection.selectedSecretId)
}
diff --git a/packages/worker/client/routes/account.tsx b/packages/worker/client/routes/account.tsx
index a6c0973a28..ce4be6348b 100644
--- a/packages/worker/client/routes/account.tsx
+++ b/packages/worker/client/routes/account.tsx
@@ -74,9 +74,14 @@ export function AccountRoute(handle: Handle) {
message = null
handle.update()
try {
+ const href =
+ typeof window === 'undefined' ? '/account' : window.location.href
+ const currentUrl = new URL(href)
+ const requestUrl = new URL(accountSecretsApiPath, currentUrl)
+ requestUrl.search = currentUrl.search
const payload = await submitApprovalRequest<
AccountSecretsPayload & { error?: string; ok?: boolean }
- >(action)
+ >(action, requestUrl.toString())
if (!payload) return
email = payload.email
approval = payload.approval
diff --git a/packages/worker/src/app/handlers/account-secrets.node.test.ts b/packages/worker/src/app/handlers/account-secrets.node.test.ts
index 3d31e7693b..c7120edec3 100644
--- a/packages/worker/src/app/handlers/account-secrets.node.test.ts
+++ b/packages/worker/src/app/handlers/account-secrets.node.test.ts
@@ -361,6 +361,29 @@ test('host approval approve persists host from allowed-host and selected secret'
)
})
+test('approval request rejects ambiguous host and package targets', async () => {
+ const handler = createAccountSecretsApiHandler(createEnv())
+ const response = await handler.action({
+ request: new Request(
+ 'https://example.com/account/secrets.json?selected=user::::cloudflareToken&allowed-host=api.cloudflare.com&package_id=pkg-123',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'approve' }),
+ },
+ ),
+ params: {},
+ } as never)
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toMatchObject({
+ ok: false,
+ error: 'Approval request contains both host and package.',
+ })
+ expect(mockModule.setSecretAllowedHosts).not.toHaveBeenCalled()
+ expect(mockModule.setSecretAllowedPackages).not.toHaveBeenCalled()
+})
+
test('account secrets payload preserves app titles and allowed packages', async () => {
mockModule.listSavedPackagesByUserId.mockResolvedValueOnce([
{
diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts
index 4d5be39d8f..2b48498c72 100644
--- a/packages/worker/src/app/handlers/account-secrets.ts
+++ b/packages/worker/src/app/handlers/account-secrets.ts
@@ -737,6 +737,9 @@ function resolveApprovalRequest(input: {
if (!parsed) {
throw new Error('Invalid approval request.')
}
+ if (input.requestedHost && input.requestedPackageId) {
+ throw new Error('Approval request contains both host and package.')
+ }
const storageContext = getSecretContextForAccountSecret(parsed)
if (input.requestedPackageId) {
return {
From e04380ebcbf54f075ffe11678503643a1bbc82df Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Tue, 28 Apr 2026 16:09:44 +0000
Subject: [PATCH 3/3] Remove stale account approval card
Co-authored-by: Kent C. Dodds
---
packages/worker/client/routes/account.tsx | 112 +---------------------
1 file changed, 1 insertion(+), 111 deletions(-)
diff --git a/packages/worker/client/routes/account.tsx b/packages/worker/client/routes/account.tsx
index ce4be6348b..0a3837e627 100644
--- a/packages/worker/client/routes/account.tsx
+++ b/packages/worker/client/routes/account.tsx
@@ -4,31 +4,23 @@ import {
cardCss,
cardTitleCss,
descriptionCss,
- getPrimaryButtonCss,
- getSecondaryButtonCss,
primaryLinkCss,
} from '#client/styles/style-primitives.ts'
import {
type AccountStatus,
- type ApprovalAction,
- type ApprovalView,
accountSecretsApiPath,
readJson,
- submitApprovalRequest,
} from '#client/routes/account-approval-shared.ts'
type AccountSecretsPayload = {
ok: true
email: string
- approval: ApprovalView | null
}
export function AccountRoute(handle: Handle) {
let status: AccountStatus = 'loading'
let email = ''
- let approval: ApprovalView | null = null
let message: string | null = null
- let submittingApprovalAction: ApprovalAction | null = null
let lastLoadedHref = ''
async function loadAccountSecrets(signal: AbortSignal) {
@@ -54,10 +46,8 @@ export function AccountRoute(handle: Handle) {
throw new Error('Unable to load your account secrets.')
}
email = payload.email
- approval = payload.approval
status = 'ready'
message = null
- submittingApprovalAction = null
handle.update()
} catch (error) {
if (signal.aborted) return
@@ -68,41 +58,6 @@ export function AccountRoute(handle: Handle) {
}
}
- async function submitApproval(action: ApprovalAction) {
- if (!approval || submittingApprovalAction != null) return
- submittingApprovalAction = action
- message = null
- handle.update()
- try {
- const href =
- typeof window === 'undefined' ? '/account' : window.location.href
- const currentUrl = new URL(href)
- const requestUrl = new URL(accountSecretsApiPath, currentUrl)
- requestUrl.search = currentUrl.search
- const payload = await submitApprovalRequest<
- AccountSecretsPayload & { error?: string; ok?: boolean }
- >(action, requestUrl.toString())
- if (!payload) return
- email = payload.email
- approval = payload.approval
- submittingApprovalAction = null
- message =
- action === 'approve'
- ? 'Approved requested host.'
- : 'Rejected host approval request.'
- handle.update()
- if (typeof window !== 'undefined' && window.location.search) {
- window.history.replaceState(null, '', '/account')
- lastLoadedHref = window.location.href
- }
- } catch (error) {
- submittingApprovalAction = null
- message =
- error instanceof Error ? error.message : 'Unable to process approval.'
- handle.update()
- }
- }
-
return () => {
const currentHref =
typeof window === 'undefined' ? '/account' : window.location.href
@@ -137,70 +92,8 @@ export function AccountRoute(handle: Handle) {
- {approval && !isRefreshingForLocationChange ? (
-
-
-
- Approve host access
-
-
- Allow {approval.requestedHost} to receive secret{' '}
- {approval.name} from the {approval.scope} scope.
-
-
- Current allowed hosts:{' '}
- {approval.currentAllowedHosts.length > 0
- ? approval.currentAllowedHosts.join(', ')
- : 'none'}
-
-
-
-
-
-
-
- ) : null}
-
{status === 'loading' ? (
-
- Loading secret approvals…
-
+ Loading account…
) : null}
{message ? (