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
2 changes: 1 addition & 1 deletion docs/contributing/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Optional Wrangler `var` (public, non-secret; see
## Hosted package app origin

Optional Wrangler `var` (public, non-secret; see
`packages/worker/src/app/app-base-url.ts` and
`packages/worker/src/app-base-url.ts` and
`packages/worker/src/app/package-app-origin.ts`):

- `PACKAGE_APP_BASE_URL` — the origin that hosted package apps are served from.
Expand Down
19 changes: 15 additions & 4 deletions docs/contributing/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,10 @@ the app origin, so the `SameSite=Lax`, `HttpOnly` `kody_session` cookie never
attaches to them and cross-origin `fetch` from package pages has no CORS grant
(`withCors` only reflects same-origin, plus `/mcp`). It must stay a **separate
registrable domain**: a subdomain of the app origin would still be same-site for
cookie purposes. `getPackageAppBaseUrl`
(`packages/worker/src/app/app-base-url.ts`) resolves it, and `getAppBaseUrl`
refuses to resolve the package-app origin as the app origin so package runtime
callbacks and first-party links always point back at the app.
cookie purposes. `getPackageAppBaseUrl` (`packages/worker/src/app-base-url.ts`)
resolves it, and `getAppBaseUrl` refuses to resolve the package-app origin as
the app origin so package runtime callbacks and first-party links always point
back at the app.

Dispatch lives in `packages/worker/src/app/package-app-origin.ts`, called first
in the Worker `fetch` handler:
Expand Down Expand Up @@ -159,6 +159,17 @@ The cross-site handoff (how the owner is recognized on the package-app origin
without giving package code a first-party session) is documented in
[`architecture/authentication.md`](./architecture/authentication.md#package-app-origin-handoff).

Because the package-app origin is one of this deployment's own origins, code
that decides whether a URL is "ours" must accept both.
`parsePackageSearchIdentity`
(`packages/worker/src/mcp/tools/package-search-identity.ts`) accepts a
`/@{username}/packages/{kodyId}` URL on either origin — so a URL copied out of a
running package app resolves to that package — while `/account/*` paths stay
app-origin only, since the package-app origin does not serve them. Untrusted
markdown is the opposite case and needs no change: `getSafeMarkdownLinkHref`
refuses any `/@...` user-scope path regardless of host, so a community README
cannot link into either origin's package surface.

Future hardening, deliberately out of scope for now: per-user subdomains
(`{username}.kodyapps.dev`) would additionally isolate one user's package apps
from another's, and one package from another. Today every package app for every
Expand Down
18 changes: 11 additions & 7 deletions docs/contributing/setup-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,17 @@ This project uses the following resources:
`PACKAGE_APP_BASE_URL` is set without `APP_BASE_URL` rather than publishing
a partial set. Any domain attached out-of-band must be added here before the
next deploy, or that deploy will remove it.
- The route deliberately does **not** live in
`packages/worker/wrangler.jsonc`: `npm run dev` runs `wrangler dev` against
the **production** environment, and Wrangler resolves local request URLs
against the first configured route, so a committed route makes every local
request arrive as `http://kodyapps.dev/...` — canonical URLs, OAuth resource
metadata, and login redirects then point at the production domain from
localhost.
- Publishing routes also flips `workers_dev` to `false`, which silently drops
the `<name>.<subdomain>.workers.dev` trigger (Cloudflare then answers that
hostname with error 1042). The generator sets `workers_dev: true` alongside
the routes so that backup access path — which MCP clients may point at, and
which the deploy's URL fallback looks for — survives.
- The routes deliberately do **not** live in `packages/worker/wrangler.jsonc`:
`npm run dev` runs `wrangler dev` against the **production** environment,
and Wrangler resolves local request URLs against the first configured route,
so a committed route makes every local request arrive as
`http://kodyapps.dev/...` — canonical URLs, OAuth resource metadata, and
login redirects then point at the production domain from localhost.
- The app origin (`heykody.dev`) stays attached out-of-band; Wrangler does not
remove routes that the config omits.
- Attaching a custom domain needs a deploy token with edit access to the
Expand Down
9 changes: 5 additions & 4 deletions docs/use/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,11 @@ error listing the available domains. The `search` meta capability (usable inside
An entire saved-package UUID or `kody.id` is treated as an exact package
identity when it resolves for the signed-in user. Kody also recognizes
current-origin `/account/packages/:packageId` URLs and owner-matching
`/@username/packages/:kodyId` URLs. Exact package identities never compete with
semantic capability results. Hidden exact query matches still require
`includeHiddenPackages: true`; exact `entity` lookup by UUID or `kody.id`
ignores the hidden discovery preference.
`/@username/packages/:kodyId` URLs — including the hosted package-app domain a
package app actually runs on, so a URL copied from an open app resolves too.
Exact package identities never compete with semantic capability results. Hidden
exact query matches still require `includeHiddenPackages: true`; exact `entity`
lookup by UUID or `kody.id` ignores the hidden discovery preference.

When a tool call also includes **`memoryContext`**, Kody may include relevant
long-term memory metadata in structured content, but broad query markdown stays
Expand Down
96 changes: 96 additions & 0 deletions packages/worker/src/app-base-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
const DEFAULT_APP_BASE_URL = 'https://heykody.dev'

type PackageAppBaseUrlEnv = {
PACKAGE_APP_BASE_URL?: string | null
WRANGLER_IS_LOCAL_DEV?: string | undefined
}

type AppBaseUrlEnv = PackageAppBaseUrlEnv & {
APP_BASE_URL?: string | null
}

function isLocallyServableHostname(hostname: string) {
return (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '[::1]' ||
hostname.endsWith('.localhost')
)
}

/**
* Resolve the origin hosted package apps are served from, or `null` when the
* deployment serves them inline on the app origin.
*
* Production sets `PACKAGE_APP_BASE_URL` to a separate registrable domain so
* author-supplied package code is cross-site from the first-party app origin
* and can never receive the owner's `kody_session` cookie. Preview, tests, and
* E2E leave it unset and keep the path-based same-origin behavior.
*
* `npm run dev` runs against the **production** Wrangler environment, so the
* committed production value reaches local dev too. A local server cannot serve
* the real package-app domain, so local dev only honors an origin it can
* actually answer on (`localhost`, `127.0.0.1`, `*.localhost`) — set
* `PACKAGE_APP_BASE_URL=http://packages.localhost:<port>` in
* `packages/worker/.env` to exercise the two-origin flow locally.
*/
export function getPackageAppBaseUrl(input: { env: PackageAppBaseUrlEnv }) {
const configured = input.env.PACKAGE_APP_BASE_URL?.trim()
if (!configured) return null

try {
const configuredUrl = new URL(configured)
// Exact 'true' like `isNonProductionRuntime`: any other value must not be
// able to switch package apps back to the app origin in a real deployment.
if (
input.env.WRANGLER_IS_LOCAL_DEV === 'true' &&
!isLocallyServableHostname(configuredUrl.hostname)
) {
return null
}
return configuredUrl.origin
} catch {
// Runtime env validation should already catch this; fall back to the
// same-origin behavior instead of routing to a bogus host.
return null
}
}

/**
* 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.
*
* Requests that arrived on the package-app origin are treated as having no
* usable request origin: that host only serves author-supplied package apps, so
* first-party links and package runtime callbacks must point at the app origin.
*/
export function getAppBaseUrl(input: {
env: AppBaseUrlEnv
requestUrl?: string | URL | null
}) {
if (input.requestUrl != null && input.requestUrl !== '') {
try {
const requestOrigin = new URL(input.requestUrl).origin
if (requestOrigin !== getPackageAppBaseUrl({ env: input.env })) {
return requestOrigin
}
} catch {
// Fall through to configured / default origin.
}
}

const configuredBaseUrl = input.env.APP_BASE_URL?.trim()
if (configuredBaseUrl) {
try {
return new URL(configuredBaseUrl).origin
} catch {
// Runtime env validation should already catch this; fall back defensively.
}
}

return DEFAULT_APP_BASE_URL
}
98 changes: 4 additions & 94 deletions packages/worker/src/app/app-base-url.ts
Original file line number Diff line number Diff line change
@@ -1,96 +1,6 @@
const DEFAULT_APP_BASE_URL = 'https://heykody.dev'

type PackageAppBaseUrlEnv = {
PACKAGE_APP_BASE_URL?: string | null
WRANGLER_IS_LOCAL_DEV?: string | undefined
}

type AppBaseUrlEnv = PackageAppBaseUrlEnv & {
APP_BASE_URL?: string | null
}

function isLocallyServableHostname(hostname: string) {
return (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '[::1]' ||
hostname.endsWith('.localhost')
)
}

/**
* Resolve the origin hosted package apps are served from, or `null` when the
* deployment serves them inline on the app origin.
*
* Production sets `PACKAGE_APP_BASE_URL` to a separate registrable domain so
* author-supplied package code is cross-site from the first-party app origin
* and can never receive the owner's `kody_session` cookie. Preview, tests, and
* E2E leave it unset and keep the path-based same-origin behavior.
*
* `npm run dev` runs against the **production** Wrangler environment, so the
* committed production value reaches local dev too. A local server cannot serve
* the real package-app domain, so local dev only honors an origin it can
* actually answer on (`localhost`, `127.0.0.1`, `*.localhost`) — set
* `PACKAGE_APP_BASE_URL=http://packages.localhost:<port>` in
* `packages/worker/.env` to exercise the two-origin flow locally.
* Compatibility re-export. Public-origin resolution moved to
* `#worker/app-base-url.ts` so MCP capabilities can resolve the app and
* package-app origins without importing from the app layer.
*/
export function getPackageAppBaseUrl(input: { env: PackageAppBaseUrlEnv }) {
const configured = input.env.PACKAGE_APP_BASE_URL?.trim()
if (!configured) return null

try {
const configuredUrl = new URL(configured)
// Exact 'true' like `isNonProductionRuntime`: any other value must not be
// able to switch package apps back to the app origin in a real deployment.
if (
input.env.WRANGLER_IS_LOCAL_DEV === 'true' &&
!isLocallyServableHostname(configuredUrl.hostname)
) {
return null
}
return configuredUrl.origin
} catch {
// Runtime env validation should already catch this; fall back to the
// same-origin behavior instead of routing to a bogus host.
return null
}
}

/**
* 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.
*
* Requests that arrived on the package-app origin are treated as having no
* usable request origin: that host only serves author-supplied package apps, so
* first-party links and package runtime callbacks must point at the app origin.
*/
export function getAppBaseUrl(input: {
env: AppBaseUrlEnv
requestUrl?: string | URL | null
}) {
if (input.requestUrl != null && input.requestUrl !== '') {
try {
const requestOrigin = new URL(input.requestUrl).origin
if (requestOrigin !== getPackageAppBaseUrl({ env: input.env })) {
return requestOrigin
}
} catch {
// Fall through to configured / default origin.
}
}

const configuredBaseUrl = input.env.APP_BASE_URL?.trim()
if (configuredBaseUrl) {
try {
return new URL(configuredBaseUrl).origin
} catch {
// Runtime env validation should already catch this; fall back defensively.
}
}

return DEFAULT_APP_BASE_URL
}
export * from '#worker/app-base-url.ts'
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
import { type CapabilityContext } from '#mcp/capabilities/types.ts'
import { addMcpServer } from '#worker/mcp-client/settings-service.ts'
import { getAppBaseUrl } from '#app/app-base-url.ts'
import { getAppBaseUrl } from '#worker/app-base-url.ts'

const outputSchema = z.object({
id: z.string(),
Expand Down
61 changes: 61 additions & 0 deletions packages/worker/src/mcp/tools/package-search-identity.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,67 @@ test('package identity parser accepts exact ids and current-origin URLs and reje
}),
).toEqual({ kind: 'not-package-identity' })

// Hosted package apps run on their own origin in production, so the URL a
// user copies from the address bar is on that host.
const hosted = { ...common, packageAppBaseUrl: 'https://kodyapps.dev' }
expect(
parsePackageSearchIdentity({
...hosted,
query: 'https://kodyapps.dev/@user/packages/daily-notes',
}),
).toEqual({ kind: 'kody-id', value: 'daily-notes', authoritative: true })
// A deep link inside a running app is not a package identity — unchanged from
// how the app origin already treated `/@user/packages/x/<rest>`.
expect(
parsePackageSearchIdentity({
...hosted,
query: 'https://kodyapps.dev/@user/packages/daily-notes/report?tab=1',
}),
).toEqual({ kind: 'not-package-identity' })
// The app origin keeps working, and relative URLs still resolve against it.
expect(
parsePackageSearchIdentity({
...hosted,
query: 'https://heykody.dev/@user/packages/daily-notes',
}),
).toEqual({ kind: 'kody-id', value: 'daily-notes', authoritative: true })
expect(
parsePackageSearchIdentity({
...hosted,
query: `/account/packages/${packageId}`,
}),
).toEqual({ kind: 'package-id', value: packageId, authoritative: true })

for (const query of [
// Another user's package, even on the package-app origin.
'https://kodyapps.dev/@other/packages/daily-notes',
// The package-app origin never serves account pages.
`https://kodyapps.dev/account/packages/${packageId}`,
// Neighbouring hosts are not this deployment.
'https://evil-kodyapps.dev/@user/packages/daily-notes',
'https://kodyapps.dev.attacker.example/@user/packages/daily-notes',
'https://user:password@kodyapps.dev/@user/packages/daily-notes',
]) {
expect(parsePackageSearchIdentity({ ...hosted, query }), query).toEqual({
kind: 'invalid-package-identity',
})
}
// Deployments that serve package apps inline (no separate origin) must not
// start accepting that host.
expect(
parsePackageSearchIdentity({
...common,
query: 'https://kodyapps.dev/@user/packages/daily-notes',
}),
).toEqual({ kind: 'invalid-package-identity' })
expect(
parsePackageSearchIdentity({
...common,
packageAppBaseUrl: 'not-a-url',
query: 'https://kodyapps.dev/@user/packages/daily-notes',
}),
).toEqual({ kind: 'invalid-package-identity' })

for (const query of [
`https://attacker.example/account/packages/${packageId}`,
`https://attacker.example/@user/packages/daily-notes`,
Expand Down
Loading
Loading