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
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,12 @@ package-app apex:
subdomain itself: sibling subdomains stay same-site until the PSL entry, so a
`SameSite=Lax` cookie would otherwise attach to a cross-subdomain mutation
from a browser holding sessions for two accounts.
- New and changed usernames are strict DNS labels (lowercase alphanumeric +
hyphens; underscores banned). Recognition of stored usernames stays lenient
(two-tier validation) so legacy underscore accounts keep display names, public
lookup, and inbound email routing; they must rename before hosted apps work
(the app origin answers their package-app entry with a `409` rename prompt).
- Usernames are strict DNS labels everywhere (lowercase alphanumeric + hyphens;
underscores banned). A two-tier lenient-recognition scheme shipped briefly to
protect legacy underscore accounts, but production had exactly one such
account; it was renamed by hand (`users.username`, `email_inbox_addresses`,
`saved_packages.name`) on 2026-08-12 and the lenient tier was removed the same
day — no legacy affordances remain.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- `packageContext.appBasePath` on a subdomain is `/packages/{kodyId}` (no
`/@{username}` prefix); inline non-production serving keeps the path-based
mount. Well-behaved packages that use `hostedUrl` / `appBasePath` stay
Expand All @@ -70,8 +71,8 @@ package-app apex:
[`security.md`](../security.md)).
- Production deploys require wildcard DNS and a `*.kodyapps.dev/*` Worker route
(see [`setup-manifest.md`](../setup-manifest.md)).
- Username renames become mandatory for underscore holders before subdomain
hosting works.
- The one production underscore account was renamed by hand on 2026-08-12; no
underscore usernames remain, so no rename affordances exist in the app.
- `parsePackageSearchIdentity`, status probes, and author docs must recognize
both subdomain URLs and legacy path shapes during transition.
- Revisit per-package subdomains only if same-owner isolation becomes a reported
Expand Down
16 changes: 6 additions & 10 deletions docs/contributing/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,12 @@ from `{username}.<package-app host>` (`buildPackageAppSubdomainOrigin` in
`document` access) never crosses accounts. The username label in the hostname
must be a valid single DNS label: lowercase letters, digits, and hyphens only,
3–32 characters, alphanumeric edges (`dnsSafeUsernamePattern` in
`packages/shared/src/public-urls.ts`). Underscores are not allowed for new or
changed usernames; accounts whose usernames still contain legacy underscores
must rename before hosted apps work on a subdomain — the app-origin entry
answers them with a `409` rename prompt instead of redirecting to a hostname
nothing can serve, and hosted-URL emission falls back to the path-based shape
for them. Recognition of _stored_ usernames elsewhere
(`getUsernameFormatValidationError`) deliberately stays lenient so legacy
accounts keep display names, public lookup, and inbound email routing. Wildcard
DNS still routes invalid or nested labels to the Worker, so hostnames that are
not exactly one valid username label fail closed with `404`.
`packages/shared/src/public-urls.ts`). Every username satisfies this shape —
underscores are rejected everywhere, and the pre-existing underscore accounts
were migrated by hand on 2026-08-12 (decision 0017), so there is no lenient
legacy tier. Wildcard DNS still routes invalid or nested labels to the Worker,
so hostnames that are not exactly one valid username label fail closed with
`404`.

Dispatch lives in `packages/worker/src/app/package-app-origin.ts`, called first
in the Worker `fetch` handler:
Expand Down
8 changes: 6 additions & 2 deletions packages/mock-servers/cloudflare/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
"main": "./src/worker.ts",
"preview_urls": true,
"observability": { "enabled": true },
// SQLite-backed classes: Cloudflare rejects creating new key-value backed
// Durable Object namespaces (error 10099), and every PR preview deploys a
// fresh mock script that runs these migrations from scratch. Long-lived
// scripts already applied these tags, so the edit only affects new scripts.
"migrations": [
{
"new_classes": ["MockCloudflareEmailMessagesDurableObject"],
"new_sqlite_classes": ["MockCloudflareEmailMessagesDurableObject"],
"tag": "v1",
},
{
"new_classes": ["MockCloudflareArtifactsDurableObject"],
"new_sqlite_classes": ["MockCloudflareArtifactsDurableObject"],
"tag": "v2",
},
],
Expand Down
15 changes: 7 additions & 8 deletions packages/shared/src/public-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ export function buildPackageAppUrl(input: {
export type PackageAppMount = 'user-subdomain' | 'username-path'

/**
* Usernames that can own a `{username}.` package-app subdomain: a valid DNS
* label (lowercase letters, digits, hyphens; 3–32 chars; alphanumeric edges).
* Legacy usernames may still contain underscores; those cannot be a hostname
* label, so subdomain URL builders fall back to the path-based mount for them.
* The username shape: a valid DNS label (lowercase letters, digits, hyphens;
* 3–32 chars; alphanumeric edges), because every user owns a `{username}.`
* subdomain on the package-app domain. Also used to validate subdomain labels
* from wildcard-routed hostnames, which can be arbitrary strings.
*/
export const dnsSafeUsernamePattern = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/

Expand Down Expand Up @@ -94,9 +94,8 @@ export function buildPackageAppSubdomainUrl(input: {

/**
* Canonical browser URL for a hosted package app: the per-user subdomain of
* the package-app origin when one is configured and the username can be a
* hostname label, or the path-based mount on the app origin otherwise (inline
* non-production serving, and legacy usernames that cannot own a subdomain).
* the package-app origin when one is configured, or the path-based mount on
* the app origin when a non-production deployment serves package apps inline.
*/
export function resolveHostedPackageAppUrl(input: {
packageAppBaseUrl: string | null
Expand All @@ -105,7 +104,7 @@ export function resolveHostedPackageAppUrl(input: {
kodyId: string
restPath?: string | null
}) {
if (input.packageAppBaseUrl && isDnsSafeUsername(input.username)) {
if (input.packageAppBaseUrl) {
return buildPackageAppSubdomainUrl({
packageAppOrigin: input.packageAppBaseUrl,
username: input.username,
Expand Down
26 changes: 0 additions & 26 deletions packages/worker/src/app/package-app-origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { createHtmlResponse } from 'remix/response/html'
import {
buildPackageAppPath,
buildPackageAppSubdomainUrl,
isDnsSafeUsername,
} from '@kody-internal/shared/public-urls.ts'
import {
getAppBaseUrl,
Expand Down Expand Up @@ -109,25 +108,6 @@ function createUnmatchedPackageAppPathResponse() {
})
}

/**
* Terminal response for a legacy username that cannot own a subdomain
* (underscores are not valid in hostnames). Redirecting would send the
* browser to a hostname the wildcard certificate and routing cannot serve, so
* fail here with the fix instead.
*/
function createSubdomainIneligibleUsernameResponse() {
return new Response(
'Hosted package apps run on a per-user subdomain, and this username contains characters that are not allowed in domain names (such as underscores). Rename the username under Account settings, then reopen the app.',
{
status: 409,
headers: {
'Cache-Control': 'no-store',
'Content-Type': 'text/plain; charset=utf-8',
},
},
)
}

/**
* The subdomain URL a package-app path is canonically served from, carrying
* over the request's query string (minus any handoff token).
Expand Down Expand Up @@ -245,9 +225,6 @@ async function redirectAppOriginToPackageAppOrigin(input: {
packageAppOrigin: string
}) {
const { request, env, url, packagePath, packageAppOrigin } = input
if (!isDnsSafeUsername(packagePath.username)) {
return createSubdomainIneligibleUsernameResponse()
}
const target = buildSubdomainTarget({ packageAppOrigin, packagePath, url })

// A non-safe method reaching the app origin is not part of the normal flow
Expand Down Expand Up @@ -293,9 +270,6 @@ function handleApexRequest(input: {
}) {
const { request, env, url, packagePath, packageAppOrigin } = input
if (packagePath) {
if (!isDnsSafeUsername(packagePath.username)) {
return createSubdomainIneligibleUsernameResponse()
}
const target = buildSubdomainTarget({ packageAppOrigin, packagePath, url })
return redirectResponse({
location: target.toString(),
Expand Down
51 changes: 0 additions & 51 deletions packages/worker/src/app/package-app-origin.workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,57 +329,6 @@ test('hosted package apps move to the owner subdomain behind a single-use handof
).toBe('/login')
})

test('legacy underscore usernames get a rename prompt instead of a broken subdomain redirect', async () => {
configureOrigins({
packageAppBaseUrl: packageAppOrigin,
runtime: 'production',
})
// Ensures schema and the session secret are in place.
await seedOwnerSessionCookie()
const legacyEmail = 'legacy-owner@example.com'
const legacyUsername = 'legacy_owner'
await seedAccount({
db: env.APP_DB,
email: legacyEmail,
username: legacyUsername,
})
const legacySetCookie = await createAuthCookie(
{
stableUserId: await createStableUserIdFromEmail(legacyEmail),
email: legacyEmail,
rememberMe: false,
},
true,
)
const legacyCookie = legacySetCookie.split(';')[0] ?? ''

// The app-origin entry terminates with the fix instead of redirecting to a
// hostname the wildcard certificate and routing cannot serve.
const entryResponse = await workerFetch(
`${appOrigin}/@${legacyUsername}/packages/demo`,
{ headers: { Cookie: legacyCookie } },
)
expect(entryResponse.status).toBe(409)
expect(entryResponse.headers.get('Location')).toBeNull()
await expect(entryResponse.text()).resolves.toContain('Rename the username')

// Legacy apex URLs for such accounts terminate the same way.
const apexResponse = await workerFetch(
`${packageAppOrigin}/@${legacyUsername}/packages/demo`,
)
expect(apexResponse.status).toBe(409)

// And an underscore hostname is never a valid user subdomain.
const subdomainResponse = await workerFetch(
`https://${legacyUsername}.packages.isolated.test/packages/demo`,
{ headers: { Cookie: legacyCookie } },
)
expect(subdomainResponse.status).toBe(404)
await expect(subdomainResponse.text()).resolves.toBe(
buildUnmatchedPackageAppOriginPathMessage(),
)
})

test('package apps stay inline on the app origin when no package-app origin is configured', async () => {
configureOrigins({ packageAppBaseUrl: undefined, runtime: 'preview' })
const sessionCookie = await seedOwnerSessionCookie()
Expand Down
7 changes: 2 additions & 5 deletions packages/worker/src/identity/platform-account-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { userExistsByUsername } from '#worker/identity/generated-username.ts'
import { normalizeEmail } from '#worker/identity/normalize-email.ts'
import { isReservedUsername } from '#worker/identity/reserved-usernames.ts'
import {
getDnsSafeUsernameValidationError,
getUsernameFormatValidationError,
normalizeUsername,
} from '#worker/identity/username.ts'
import { createStableUserIdFromEmail } from '#worker/user-id.ts'
Expand Down Expand Up @@ -54,10 +54,7 @@ export async function createPlatformAccount(input: {
}

const username = normalizeUsername(input.username)
// Platform accounts are new accounts too: their usernames must be strict
// DNS labels so they can own a package-app subdomain (they only bypass the
// reserved-list restriction, not the format rules).
const formatError = getDnsSafeUsernameValidationError(username)
const formatError = getUsernameFormatValidationError(username)
if (formatError) {
throw new PlatformAccountCreateError('invalid_username', formatError)
}
Expand Down
20 changes: 6 additions & 14 deletions packages/worker/src/identity/username.node.test.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,25 @@
import { expect, test } from 'vitest'
import {
getDnsSafeUsernameValidationError,
getUsernameFormatValidationError,
getUsernameValidationError,
resolveDisplayName,
usernameFromEmail,
usernameRequirements,
} from './username.ts'

test('new and changed usernames must be DNS labels (no underscores)', () => {
expect(getDnsSafeUsernameValidationError('some_user')).toBe(
test('usernames must be DNS labels (no underscores)', () => {
expect(getUsernameFormatValidationError('some_user')).toBe(
usernameRequirements,
)
expect(getUsernameValidationError('user_name')).toBe(usernameRequirements)
expect(getDnsSafeUsernameValidationError('some-user')).toBeNull()
expect(getUsernameValidationError('some-user')).toBeNull()
})

test('existing usernames with legacy underscores are still recognized', () => {
// Recognition of stored usernames stays lenient so legacy accounts keep
// display names, public lookup, and inbound email routing; only subdomain
// hosting and new-username validation require the strict DNS-label shape.
expect(getUsernameFormatValidationError('some_user')).toBeNull()
expect(getUsernameFormatValidationError('has space')).toBe(
usernameRequirements,
)
expect(getUsernameFormatValidationError('some-user')).toBeNull()
expect(getUsernameValidationError('some-user')).toBeNull()
expect(
resolveDisplayName({ email: 'legacy@example.com', username: 'some_user' }),
).toBe('some_user')
resolveDisplayName({ email: 'user@example.com', username: 'some_user' }),
).toBe('user')
})

test('usernameFromEmail maps underscores in the email local part to hyphens', () => {
Expand Down
36 changes: 6 additions & 30 deletions packages/worker/src/identity/username.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,18 @@ import { getReservedUsernameError } from '#worker/identity/reserved-usernames.ts
export const usernameRequirements =
'Username must be 3 to 32 characters, use only letters, numbers, and hyphens, and start and end with a letter or number.'

/**
* The shape usernames could take before the underscore ban. Existing accounts
* may still carry underscores, so *recognition* of stored usernames (display
* names, public user lookup, inbound email routing, `/@{username}` path
* parsing) stays lenient — otherwise those accounts would silently lose every
* username-addressed surface, not just hosted package-app subdomains.
*/
const legacyUsernamePattern = /^[a-z0-9](?:[a-z0-9_-]{1,30}[a-z0-9])$/

export function normalizeUsername(value: unknown) {
return typeof value === 'string' ? value.trim().toLowerCase() : ''
}

/**
* Recognition gate for usernames that may already exist in the database.
* Accepts the legacy underscore shape; use
* `getDnsSafeUsernameValidationError` when validating a new or changed
* username or a package-app subdomain label.
* Every username is a valid DNS label (`dnsSafeUsernamePattern` from
* `@kody-internal/shared/public-urls.ts`): each user owns a `{username}.`
* subdomain on the package-app domain. There is no lenient legacy shape —
* the underscore-era usernames were migrated out of production on
* 2026-08-12 (decision 0017).
*/
export function getUsernameFormatValidationError(username: string) {
if (!username) {
return 'Username is required.'
}
if (!legacyUsernamePattern.test(username)) {
return usernameRequirements
}
return null
}

/**
* Strict DNS-label validation (`dnsSafeUsernamePattern` from
* `@kody-internal/shared/public-urls.ts`) for new/changed usernames and
* package-app subdomain labels. Rejects the legacy underscore shape.
*/
export function getDnsSafeUsernameValidationError(username: string) {
if (!username) {
return 'Username is required.'
}
Expand Down Expand Up @@ -76,9 +53,8 @@ export function resolveDisplayName(input: { email: string; username: string }) {
: input.username
}

/** Validation for a new or changed username: strict DNS label + reserved list. */
export function getUsernameValidationError(username: string) {
const formatError = getDnsSafeUsernameValidationError(username)
const formatError = getUsernameFormatValidationError(username)
if (formatError) {
return formatError
}
Expand Down
12 changes: 2 additions & 10 deletions packages/worker/src/mcp/capabilities/packages/package-app-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { bytesToBase64 } from '@kody-internal/shared/base64.ts'
import {
isDnsSafeUsername,
resolveHostedPackageAppUrl,
} from '@kody-internal/shared/public-urls.ts'
import { resolveHostedPackageAppUrl } from '@kody-internal/shared/public-urls.ts'
import { z } from 'zod'
import { McpCallerError } from '#mcp/caller-error.ts'
import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts'
Expand Down Expand Up @@ -412,16 +409,11 @@ export const packageAppFetchCapability = defineDomainCapability(
kodyId: savedPackage.kodyId,
restPath: restPath === '/' ? null : restPath,
})
// Mount matches how resolveHostedPackageAppUrl addressed the app:
// legacy usernames that cannot own a subdomain keep the path mount.
const packagePath: PackageAppPath = {
username: owner.ownerScope,
kodyId: savedPackage.kodyId,
restPath: restPathOnly,
mount:
packageAppOrigin && isDnsSafeUsername(owner.ownerScope)
? 'user-subdomain'
: 'username-path',
mount: packageAppOrigin ? 'user-subdomain' : 'username-path',
}

const requestHeaders = collectSafeRequestHeaders(args.headers)
Expand Down
Loading