Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/contributing/architecture/request-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ library’s built-in handler for that path advertises `resource` as the request
our own metadata on that URL keeps the RFC 8707 `resource` value consistent for
clients (e.g. some MCP stacks) that discover metadata from the 401
`resource_metadata` URL and would otherwise get `invalid_target` at the token
endpoint.
endpoint. Protected-resource metadata and MCP auth challenges resolve the origin
from the inbound request URL (via `getAppBaseUrl`) so clients connecting through
`heykody.dev` or a workers.dev host get matching resource values. `APP_BASE_URL`
is only the fallback for background work with no request URL.

## Routing order

Expand Down
20 changes: 13 additions & 7 deletions docs/contributing/setup-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,11 @@ automatically:

- `COOKIE_SECRET` (generate with `openssl rand -hex 32`)
- `SECRET_STORE_KEY` (required; generate with `openssl rand -base64 48`)
- `APP_BASE_URL` (optional; defaults to request origin for most app URLs,
example `https://app.example.com`; also sets the canonical public origin used
for MCP auth metadata, generated UI resources, and email links. Password reset
email sends require this value and use `kody@<hostname>` as the sender.)
- `APP_BASE_URL` (optional; used as the fallback public origin when no request
URL is available — e.g. workflows and email. Example `https://heykody.dev`.
Most request-scoped app/MCP URLs use the inbound request origin so OAuth
metadata matches the host the client connected to. Password reset email sends
require this value and use `kody@<hostname>` as the sender.)
- `APP_COMMIT_SHA` (optional; set automatically by deploy workflows for
version-aware `/health` checks)
- `CLOUDFLARE_ACCOUNT_ID` (required for the Cloudflare Email Service REST API
Expand Down Expand Up @@ -121,8 +122,10 @@ Configure these GitHub Actions secrets and variables for workflows:
- `COOKIE_SECRET` (same format as local)
- `SECRET_STORE_KEY` (same format as local; required for deploys)
- `APP_BASE_URL` (optional GitHub Actions **variable**, used by the production
deploy as the canonical public app origin, the password-reset email sender
hostname, and written into the generated Worker `vars` config before deploy)
deploy as the fallback public app origin when no request URL is available —
workflows, password-reset email sender hostname — and written into the
generated Worker `vars` config before deploy. Request-scoped MCP/app URLs use
the inbound request origin.)
- `AI_GATEWAY_ID` (optional for production deploys; enables AI Gateway routing
for Workers AI embeddings)
- `AI_GATEWAY_ID_PREVIEW` (optional for preview deploys; enables AI Gateway
Expand Down Expand Up @@ -152,12 +155,15 @@ How to get/set each value:
- Generate locally: `openssl rand -hex 32`
- Store the exact value as a repository secret in GitHub Actions.
- `APP_BASE_URL` (optional)
- Use your production app URL (for example `https://app.example.com`).
- Use your production app URL (for example `https://heykody.dev`) as the
fallback public origin for workflows and password-reset email.
- Add it when password reset email should send; the sender is derived as
`kody@<hostname>`, so verify that sender/domain in Cloudflare Email Service.
- It also lets deploy-time health/version checks use a fixed URL.
- Production CI writes this into the generated Wrangler `vars` config before
deploy, rather than syncing it as a Worker secret.
- Request-scoped MCP/app URLs use the inbound request origin so OAuth resource
metadata matches the host the client connected to.
- Do not also upload `APP_BASE_URL` through `wrangler secret bulk` or pass it
as a deploy-time `--var`, because Wrangler treats that as a conflicting
binding name.
Expand Down
39 changes: 39 additions & 0 deletions packages/worker/src/app/app-base-url.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { expect, test } from 'vitest'
import { getAppBaseUrl } from './app-base-url.ts'

test('getAppBaseUrl prefers the request origin when present', () => {
expect(
getAppBaseUrl({
env: { APP_BASE_URL: 'https://heykody.dev' },
requestUrl: 'https://kody-production.kody-a99.workers.dev/mcp',
}),
).toBe('https://kody-production.kody-a99.workers.dev')

expect(
getAppBaseUrl({
env: { APP_BASE_URL: 'https://configured.example' },
requestUrl: 'https://heykody.dev/mcp',
}),
).toBe('https://heykody.dev')
})

test('getAppBaseUrl falls back to APP_BASE_URL then heykody.dev', () => {
expect(
getAppBaseUrl({
env: { APP_BASE_URL: 'https://configured.example/path' },
}),
).toBe('https://configured.example')

expect(
getAppBaseUrl({
env: { APP_BASE_URL: '' },
}),
).toBe('https://heykody.dev')

expect(
getAppBaseUrl({
env: {},
requestUrl: null,
}),
).toBe('https://heykody.dev')
})
22 changes: 20 additions & 2 deletions packages/worker/src/app/app-base-url.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
const DEFAULT_APP_BASE_URL = 'https://heykody.dev'

/**
* Resolve the public app origin for request-scoped work.
*
* Prefer the request origin when a real request URL is available so MCP OAuth
* metadata and app links match the host the client actually connected to.
* Fall back to `APP_BASE_URL`, then the production default, for background work
* (workflows, email, etc.) that has no inbound request.
*/
export function getAppBaseUrl(input: {
env: {
APP_BASE_URL?: string | null
}
requestUrl: string | URL
requestUrl?: string | URL | null
}) {
if (input.requestUrl != null && input.requestUrl !== '') {
try {
return new URL(input.requestUrl).origin
} catch {
// Fall through to configured / default origin.
}
}

const configuredBaseUrl = input.env.APP_BASE_URL?.trim()
if (configuredBaseUrl) {
try {
Expand All @@ -13,5 +31,5 @@ export function getAppBaseUrl(input: {
}
}

return new URL(input.requestUrl).origin
return DEFAULT_APP_BASE_URL
}
6 changes: 6 additions & 0 deletions packages/worker/src/app/auth-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export function setAuthSessionSecret(secret: string) {
})
}

/** Clears configured session cookie state so SSR entry points re-initialize. */
export function resetAuthSessionSecretForTests() {
sessionCookie = null
sessionSecret = null
}

function getSessionCookie() {
if (!sessionCookie) {
throw new Error('Session cookie not configured. Call setAuthSessionSecret.')
Expand Down
26 changes: 26 additions & 0 deletions packages/worker/src/app/ssr-render.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, test, vi } from 'vitest'
import { type CommunityListingWithAggregates } from '#worker/community/types.ts'
import {
createAuthCookie,
resetAuthSessionSecretForTests,
setAuthSessionSecret,
type AuthSession,
} from '#app/auth-session.ts'
Expand Down Expand Up @@ -279,3 +280,28 @@ test('SSR HTML routes render page content and embedded loader data', async () =>
'/reset-password?token=reset-token',
)
})

test('renderAppPage configures session secret before reading cookies', async () => {
resetDataCacheForTests()
resetAuthSessionSecretForTests()
const env = createTestEnv(createUserTestDb([]))

const response = await renderAppPage({
request: new Request('https://example.com/oauth/authorize', {
headers: { Cookie: 'kody_session=stale-or-unsigned; other=1' },
}),
env,
loaderData: {
oauthAuthorize: {
ok: true,
client: { id: 'client-1', name: 'Cursor' },
scopes: ['profile', 'email'],
},
},
})

expect(response.status).toBe(200)
const html = await readResponseText(response)
expect(html).toContain('Authorize access')
expect(html).not.toContain('OAuth authorization failed')
})
4 changes: 4 additions & 0 deletions packages/worker/src/app/ssr-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
buildStylesheetHref,
getClientBuildId,
} from '#app/client-build-id.ts'
import { setAuthSessionSecret } from '#app/auth-session.ts'
import { getEnv } from '#app/env.ts'
import { type AppLoaderData, getRequestUrl } from '#app/loader-data.ts'
import { getRequestDataCacheLookup } from '#app/request-cache.ts'
Expand Down Expand Up @@ -38,6 +39,9 @@ export async function renderAppPage(input: RenderAppPageInput) {
status,
extraSetCookies,
} = input
// OAuth authorize (and any SSR entry) can run outside appHandler, so configure
// the session cookie before reading request cookies.
setAuthSessionSecret(getEnv(env).COOKIE_SECRET)
const { session, setCookie } = await loadSessionInfo(request, env)
const requestUrl = new URL(request.url)
const url = getRequestUrl(request)
Expand Down
1 change: 0 additions & 1 deletion packages/worker/src/email/package-subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ export async function dispatchInboundEmailSubscriptionEvents(input: {
}) {
const baseUrl = getAppBaseUrl({
env: input.env,
requestUrl: 'https://kody.invalid',
})
const attachments = await listEmailAttachmentsForMessage({
db: input.env.APP_DB,
Expand Down
3 changes: 2 additions & 1 deletion packages/worker/src/execute-maintenance.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { exports as workerExports } from 'cloudflare:workers'
import { getAppBaseUrl } from '#app/app-base-url.ts'
import { createExecuteExecutor } from '#mcp/executor.ts'
import { handleSecretMaintenanceRequest } from './maintenance-handler.ts'
import { getErrorMessage } from '#mcp/capabilities/error-message.ts'
Expand All @@ -22,7 +23,7 @@ export async function runExecuteSmokeCheck(env: Env) {
)
}

const origin = env.APP_BASE_URL?.trim() || 'https://heykody.dev'
const origin = getAppBaseUrl({ env })
const executor = createExecuteExecutor({
env,
exports: workerExports,
Expand Down
15 changes: 7 additions & 8 deletions packages/worker/src/mcp-auth.workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ function createContext() {

test('protected resource metadata and auth challenge resolve origin consistently', async () => {
const requestOrigin = 'https://example.com'
const workersDevOrigin = 'https://kody-production.kentcdodds.workers.dev'
const appBaseUrl = 'https://heykody.dev'

const requestOriginMetadataResponse = handleProtectedResourceMetadata(
Expand All @@ -125,17 +126,17 @@ test('protected resource metadata and auth challenge resolve origin consistently
buildProtectedResourceMetadata(requestOrigin),
)

// Request origin wins even when APP_BASE_URL is configured differently —
// MCP clients require resource metadata to match the URL they connected to.
const appBaseUrlMetadataResponse = handleProtectedResourceMetadata(
new Request(
`https://kody-production.kentcdodds.workers.dev${protectedResourceMetadataPath}`,
),
new Request(`${workersDevOrigin}${protectedResourceMetadataPath}`),
{
APP_BASE_URL: appBaseUrl,
} as Env,
)
expect(appBaseUrlMetadataResponse.status).toBe(200)
expect(await appBaseUrlMetadataResponse.json()).toEqual(
buildProtectedResourceMetadata(appBaseUrl),
buildProtectedResourceMetadata(workersDevOrigin),
)

const requestOriginUnauthorizedResponse = await handleMcpRequest({
Expand All @@ -151,9 +152,7 @@ test('protected resource metadata and auth challenge resolve origin consistently
)

const appBaseUrlUnauthorizedResponse = await handleMcpRequest({
request: new Request(
`https://kody-production.kentcdodds.workers.dev${mcpResourcePath}`,
),
request: new Request(`${workersDevOrigin}${mcpResourcePath}`),
env: createEnv(createHelpers(), {
APP_BASE_URL: appBaseUrl,
}),
Expand All @@ -163,7 +162,7 @@ test('protected resource metadata and auth challenge resolve origin consistently
expect(appBaseUrlUnauthorizedResponse.status).toBe(401)
expectAuthenticateHeader(
appBaseUrlUnauthorizedResponse.headers.get('WWW-Authenticate') ?? '',
appBaseUrl,
workersDevOrigin,
)
})

Expand Down
25 changes: 13 additions & 12 deletions packages/worker/src/mcp/generated-ui-app-base-url.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,26 @@ import { renderGeneratedUiRuntimeHtmlEntry } from '#mcp/apps/generated-ui-runtim
const mcpResourcePath = '/mcp'

/**
* Full MCP E2E cannot assert APP_BASE_URL that differs from the Streamable HTTP
* server URL: @modelcontextprotocol/sdk OAuth rejects protected-resource metadata
* when `resource` does not match the MCP endpoint origin (see `selectResourceURL`
* in client/auth.js). Production uses the same public origin for both.
* Full MCP E2E cannot assert a canonical origin that differs from the
* Streamable HTTP server URL: @modelcontextprotocol/sdk OAuth rejects
* protected-resource metadata when `resource` does not match the MCP endpoint
* origin (see `selectResourceURL` in client/auth.js). Request-origin resolution
* keeps metadata aligned with the host the client connected to.
*/
test('canonical APP_BASE_URL drives runtime script href and MCP URL for widget domain', async () => {
test('request origin drives runtime script href and MCP URL for widget domain', async () => {
const appBase = getAppBaseUrl({
env: { APP_BASE_URL: 'https://app.example/custom-path' },
requestUrl: 'http://127.0.0.1:9999/mcp',
env: { APP_BASE_URL: 'https://configured.example' },
requestUrl: 'https://heykody.dev/mcp',
})
expect(appBase).toBe('https://app.example')
expect(appBase).toBe('https://heykody.dev')

const html = renderGeneratedUiRuntimeHtmlEntry(appBase)
expect(html).toMatch(/https:\/\/app\.example\/mcp-apps\/[^"]+/)
expect(html).not.toContain('127.0.0.1:9999')
expect(html).toMatch(/https:\/\/heykody\.dev\/mcp-apps\/[^"]+/)
expect(html).not.toContain('configured.example')

const mcpServerUrl = new URL(mcpResourcePath, appBase).toString()
expect(mcpServerUrl).toBe('https://app.example/mcp')
expect(mcpServerUrl).toBe('https://heykody.dev/mcp')
expect(await computeClaudeWidgetDomain(mcpServerUrl)).toBe(
await computeClaudeWidgetDomain('https://app.example/mcp'),
await computeClaudeWidgetDomain('https://heykody.dev/mcp'),
)
})
2 changes: 0 additions & 2 deletions packages/worker/src/package-runtime/package-workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1022,7 +1022,6 @@ export class DynamicCallableWorkflowBase extends WorkflowEntrypoint<
env: this.env,
baseUrl: getAppBaseUrl({
env: this.env,
requestUrl: 'https://kody.invalid/',
}),
token: {
tokenId: packageWorkflowTokenId,
Expand Down Expand Up @@ -1070,7 +1069,6 @@ export class DynamicCallableWorkflowBase extends WorkflowEntrypoint<
createMcpCallerContext({
baseUrl: getAppBaseUrl({
env: this.env,
requestUrl: 'https://kody.invalid/',
}),
user: {
userId: payload.userId,
Expand Down
Loading