diff --git a/docs/contributing/environment-variables.md b/docs/contributing/environment-variables.md index 628a27963..f368c5cc4 100644 --- a/docs/contributing/environment-variables.md +++ b/docs/contributing/environment-variables.md @@ -116,23 +116,24 @@ confirmed non-production runtimes; see `packages/worker/src/app-base-url.ts` and - `PACKAGE_APP_BASE_URL` — the **apex** origin of the package-app domain that hosted package apps are served from. Production sets `https://kodyapps.dev` in - `packages/worker/wrangler.jsonc`, and the deploy derives a Workers - `custom_domain` route for the apex (which provisions its DNS and certificate) - plus a wildcard **zone route** (`*./*`) for per-user subdomains — - Cloudflare custom domains cannot be wildcards, and zone routes do not create - DNS records, so production CI ensures the proxied wildcard DNS record - separately (see [setup-manifest.md](./setup-manifest.md)). Each owner's apps - are addressed at `https://{username}./packages/{kodyId}/...`; the - apex itself serves only redirects (legacy `/@user/packages/...` paths to the - owning subdomain, `/` to the app origin). It **must be a separate registrable - domain** from `APP_BASE_URL`: that is what makes author-supplied package code - cross-site, so the `SameSite=Lax` `kody_session` cookie never reaches it. - Production origin validation also requires `APP_BASE_URL` so this relationship - can be checked at runtime. Production returns `500` for package-app requests - when this value is missing, invalid, equal to `APP_BASE_URL`, or on the same - registrable domain; it never falls back to inline serving. Preview, tests, and - E2E may leave it unset and keep serving package apps inline on the app origin - at `/@{username}/packages/*`. + `packages/worker/wrangler.jsonc`, and the deploy publishes **zone routes** for + the apex (`/*`) and the per-user wildcard (`*./*`) on + the runtime Worker — never a custom domain in this zone (replacing a zone's + route table detaches its custom domains and deletes their DNS records). Zone + routes do not create DNS records, so production CI ensures proxied placeholder + records for both names separately (see + [setup-manifest.md](./setup-manifest.md)). Each owner's apps are addressed at + `https://{username}./packages/{kodyId}/...`; the apex itself serves + only redirects (legacy `/@user/packages/...` paths to the owning subdomain, + `/` to the app origin). It **must be a separate registrable domain** from + `APP_BASE_URL`: that is what makes author-supplied package code cross-site, so + the `SameSite=Lax` `kody_session` cookie never reaches it. Production origin + validation also requires `APP_BASE_URL` so this relationship can be checked at + runtime. Production returns `500` for package-app requests when this value is + missing, invalid, equal to `APP_BASE_URL`, or on the same registrable domain; + it never falls back to inline serving. Preview, tests, and E2E may leave it + unset and keep serving package apps inline on the app origin at + `/@{username}/packages/*`. `npm run dev` runs the **production** Wrangler environment, so the committed production value reaches local dev too; `getPackageAppBaseUrl` ignores an diff --git a/docs/contributing/setup-manifest.md b/docs/contributing/setup-manifest.md index 8649aba3e..d5bd1dd75 100644 --- a/docs/contributing/setup-manifest.md +++ b/docs/contributing/setup-manifest.md @@ -102,33 +102,36 @@ This project uses the following resources: Workers AI binding options. - Second registrable domain for hosted package apps - Production: `kodyapps.dev` (zone in the same Cloudflare account, on - Cloudflare nameservers), attached to the production Worker as a Workers - **custom domain**, which provisions the apex DNS record and edge - certificate. Per-user package apps are served from - `https://{username}.kodyapps.dev/packages/{kodyId}/...`; the apex stays - attached for legacy redirects (`/@user/packages/...` → per-user subdomain, - `/` → app origin). - - **Wildcard DNS (one-time per zone).** Zone routes do not create DNS records. - Production CI (`tools/ci/production-resources.ts ensure`) idempotently - ensures a proxied wildcard record in the package-app zone: `*.kodyapps.dev` - → AAAA `100::` (orange-cloud proxied). The deploy token needs **DNS:Edit** - on that zone (in addition to Workers deploy permissions). Forks must create - the zone and either run `ensure` or add the record manually before the first - per-user-subdomain deploy. - - **Wildcard zone route.** `writeGeneratedWranglerConfig` - (`tools/ci/resource-utils.ts`) also publishes - `{ pattern: "*.kodyapps.dev/*", zone_name: "kodyapps.dev" }` alongside the - apex `custom_domain` route. Cloudflare custom domains cannot be wildcards, - so per-user hosts use a zone route instead. Cloudflare Universal SSL covers - one wildcard label (`*.kodyapps.dev`), which is enough for - `{username}.kodyapps.dev`. + Cloudflare nameservers), served by the runtime Worker via **zone routes** + plus proxied placeholder DNS records — deliberately **not** a Workers custom + domain. Per-user package apps are served from + `https://{username}.kodyapps.dev/packages/{kodyId}/...`; the apex serves + legacy redirects (`/@user/packages/...` → per-user subdomain, `/` → app + origin). + - **No custom domain in this zone (incident-tested).** The deploy publishes + this zone's Worker route table, and replacing a zone's routes detaches any + Workers custom domain in that zone and deletes its DNS record — that took + `kodyapps.dev` down on 2026-08-11. Custom domains stay reserved for the + app-origin zones, whose route tables the deploy never publishes. + - **DNS records (idempotent, per deploy).** Zone routes do not create DNS + records. Production CI (`tools/ci/production-resources.ts ensure`) + idempotently ensures proxied records for both names in the package-app zone: + `kodyapps.dev` and `*.kodyapps.dev` → AAAA `100::` (orange-cloud proxied). + The deploy token needs **DNS:Edit** on that zone (in addition to Workers + deploy permissions). Forks must create the zone and either run `ensure` or + add the records manually before the first per-user-subdomain deploy. + - **Zone routes.** `tools/ci/runtime-worker-config.ts` publishes + `{ pattern: "kodyapps.dev/*", zone_name: "kodyapps.dev" }` and + `{ pattern: "*.kodyapps.dev/*", zone_name: "kodyapps.dev" }` on the runtime + Worker. Cloudflare Universal SSL covers one wildcard label + (`*.kodyapps.dev`), which is enough for `{username}.kodyapps.dev`. - The attach happens on deploy, but the routes are **generated, not committed**: `writeGeneratedWranglerConfig` derives one `custom_domain` - route per base-URL var (`APP_BASE_URL`, `APP_LEGACY_HOSTS`, and - `PACKAGE_APP_BASE_URL`) plus the wildcard zone route while writing - `packages/worker/wrangler-production.generated.json`. Those vars are the - single source of truth for both the hosts the Worker routes on and the - domains the deploy attaches, so the two cannot drift. + route per app-origin var (`APP_BASE_URL` and `APP_LEGACY_HOSTS`) while + writing `packages/worker/wrangler-production.generated.json`; the + package-app zone routes are generated into the runtime Worker config. Those + vars are the single source of truth for both the hosts the Workers route on + and the domains the deploy attaches, so the two cannot drift. - **`routes` replaces the Worker's whole route set — it does not add to it.** Omitting a previously attached custom domain detaches that origin and deletes its DNS record. The generator therefore always lists the app origin @@ -327,11 +330,12 @@ automatically: - `PACKAGE_APP_BASE_URL` (Wrangler `var`; required in production and optional for confirmed local/preview/test runtimes; origin for hosted package apps. Production sets `https://kodyapps.dev` in `packages/worker/wrangler.jsonc`, - and the deploy attaches that apex as a Workers custom domain plus a wildcard - zone route (`*.kodyapps.dev/*`) from the generated config (see the Cloudflare - resources list above). Per-user apps use `{username}.kodyapps.dev` subdomains; - production CI ensures the proxied wildcard DNS record. Must be a **separate - registrable domain** from `APP_BASE_URL` — see + and the deploy publishes apex and wildcard zone routes (`kodyapps.dev/*`, + `*.kodyapps.dev/*`) on the runtime Worker (see the Cloudflare resources list + above — never a custom domain in this zone). Per-user apps use + `{username}.kodyapps.dev` subdomains; production CI ensures the proxied apex + and wildcard DNS records. Must be a **separate registrable domain** from + `APP_BASE_URL` — see [Hosted package app origin isolation](./security.md#hosted-package-app-origin-isolation). Local dev ignores any value it cannot serve itself, and preview/test leave it unset, so those keep serving package apps inline on the app origin. Point it diff --git a/tools/ci/production-resources.ts b/tools/ci/production-resources.ts index 2f47dcec5..c47b1acf6 100644 --- a/tools/ci/production-resources.ts +++ b/tools/ci/production-resources.ts @@ -3,7 +3,7 @@ import { ensureArtifactsAccountEventSubscription, ensureCloudflareQueue, ensureEmailSendingEventSubscription, - ensurePackageAppWildcardDnsRecord, + ensurePackageAppDnsRecords, ensureR2Bucket, fail, isValidBareHostname, @@ -643,7 +643,7 @@ async function ensureProductionResources(options: CliOptions) { }) if (bindings.packageAppHostname) { - await ensurePackageAppWildcardDnsRecord({ + await ensurePackageAppDnsRecords({ accountId: accountId ?? 'dry-run-account', apiToken: apiToken ?? 'dry-run-token', packageAppHostname: bindings.packageAppHostname, diff --git a/tools/ci/resource-utils.node.test.ts b/tools/ci/resource-utils.node.test.ts index 8ae4faa40..b96b555d8 100644 --- a/tools/ci/resource-utils.node.test.ts +++ b/tools/ci/resource-utils.node.test.ts @@ -12,7 +12,7 @@ import { ensureArtifactsAccountEventSubscription, ensureCloudflareQueue, ensureEmailSendingEventSubscription, - ensurePackageAppWildcardDnsRecord, + ensurePackageAppDnsRecords, isR2BucketAlreadyExistsOutput, isRetryableCloudflareApiError, isWranglerNotFoundOutput, @@ -869,7 +869,7 @@ test('ensureArtifactsAccountEventSubscription creates account-level lifecycle su ) }) -test('ensurePackageAppWildcardDnsRecord creates a proxied wildcard AAAA record', async () => { +test('ensurePackageAppDnsRecords creates proxied apex and wildcard AAAA records', async () => { consoleError.mockImplementation(() => {}) const fetcher = vi .fn() @@ -879,12 +879,20 @@ test('ensurePackageAppWildcardDnsRecord creates a proxied wildcard AAAA record', result: [{ id: 'zone-kodyapps', name: 'kodyapps.dev' }], }), ) + .mockResolvedValueOnce(Response.json({ success: true, result: [] })) .mockResolvedValueOnce( Response.json({ success: true, - result: [], + result: { + id: 'dns-apex', + type: 'AAAA', + name: 'kodyapps.dev', + content: '100::', + proxied: true, + }, }), ) + .mockResolvedValueOnce(Response.json({ success: true, result: [] })) .mockResolvedValueOnce( Response.json({ success: true, @@ -898,7 +906,7 @@ test('ensurePackageAppWildcardDnsRecord creates a proxied wildcard AAAA record', }), ) - await ensurePackageAppWildcardDnsRecord({ + await ensurePackageAppDnsRecords({ accountId: 'account-1', apiToken: 'token-1', packageAppHostname: 'kodyapps.dev', @@ -911,16 +919,35 @@ test('ensurePackageAppWildcardDnsRecord creates a proxied wildcard AAAA record', 'https://api.cloudflare.com/client/v4/zones?name=kodyapps.dev&account.id=account-1&status=active', expect.objectContaining({ method: 'GET' }), ) - // The list query is name-only on purpose: a type filter would hide - // conflicting A/CNAME records at the wildcard name. + // The list queries are name-only on purpose: a type filter would hide + // conflicting A/CNAME records at the same name. expect(fetcher).toHaveBeenNthCalledWith( 2, - `https://api.cloudflare.com/client/v4/zones/zone-kodyapps/dns_records?name=${encodeURIComponent('*.kodyapps.dev')}`, + 'https://api.cloudflare.com/client/v4/zones/zone-kodyapps/dns_records?name=kodyapps.dev', expect.objectContaining({ method: 'GET' }), ) expect(fetcher).toHaveBeenNthCalledWith( 3, 'https://api.cloudflare.com/client/v4/zones/zone-kodyapps/dns_records', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + type: 'AAAA', + name: 'kodyapps.dev', + content: '100::', + proxied: true, + ttl: 1, + }), + }), + ) + expect(fetcher).toHaveBeenNthCalledWith( + 4, + `https://api.cloudflare.com/client/v4/zones/zone-kodyapps/dns_records?name=${encodeURIComponent('*.kodyapps.dev')}`, + expect.objectContaining({ method: 'GET' }), + ) + expect(fetcher).toHaveBeenNthCalledWith( + 5, + 'https://api.cloudflare.com/client/v4/zones/zone-kodyapps/dns_records', expect.objectContaining({ method: 'POST', body: JSON.stringify({ @@ -934,7 +961,7 @@ test('ensurePackageAppWildcardDnsRecord creates a proxied wildcard AAAA record', ) }) -test('ensurePackageAppWildcardDnsRecord fails on a conflicting record of another type', async () => { +test('ensurePackageAppDnsRecords fails on a conflicting record of another type', async () => { consoleError.mockImplementation(() => {}) const exit = vi.spyOn(process, 'exit').mockImplementation((() => { throw new Error('process.exit called') @@ -954,7 +981,7 @@ test('ensurePackageAppWildcardDnsRecord fails on a conflicting record of another { id: 'dns-conflicting', type: 'CNAME', - name: '*.kodyapps.dev', + name: 'kodyapps.dev', content: 'somewhere-else.example', proxied: false, }, @@ -963,7 +990,7 @@ test('ensurePackageAppWildcardDnsRecord fails on a conflicting record of another ) await expect( - ensurePackageAppWildcardDnsRecord({ + ensurePackageAppDnsRecords({ accountId: 'account-1', apiToken: 'token-1', packageAppHostname: 'kodyapps.dev', @@ -978,7 +1005,7 @@ test('ensurePackageAppWildcardDnsRecord fails on a conflicting record of another exit.mockRestore() }) -test('ensurePackageAppWildcardDnsRecord fails on a conflict even when the required record exists', async () => { +test('ensurePackageAppDnsRecords fails on a conflict even when the required record exists', async () => { consoleError.mockImplementation(() => {}) const exit = vi.spyOn(process, 'exit').mockImplementation((() => { throw new Error('process.exit called') @@ -998,14 +1025,14 @@ test('ensurePackageAppWildcardDnsRecord fails on a conflict even when the requir { id: 'dns-required', type: 'AAAA', - name: '*.kodyapps.dev', + name: 'kodyapps.dev', content: '100::', proxied: true, }, { id: 'dns-stray', type: 'A', - name: '*.kodyapps.dev', + name: 'kodyapps.dev', content: '192.0.2.1', proxied: false, }, @@ -1014,7 +1041,7 @@ test('ensurePackageAppWildcardDnsRecord fails on a conflict even when the requir ) await expect( - ensurePackageAppWildcardDnsRecord({ + ensurePackageAppDnsRecords({ accountId: 'account-1', apiToken: 'token-1', packageAppHostname: 'kodyapps.dev', @@ -1028,7 +1055,7 @@ test('ensurePackageAppWildcardDnsRecord fails on a conflict even when the requir exit.mockRestore() }) -test('ensurePackageAppWildcardDnsRecord reuses an existing proxied wildcard record', async () => { +test('ensurePackageAppDnsRecords reuses existing proxied apex and wildcard records', async () => { consoleError.mockImplementation(() => {}) const fetcher = vi .fn() @@ -1038,6 +1065,20 @@ test('ensurePackageAppWildcardDnsRecord reuses an existing proxied wildcard reco result: [{ id: 'zone-kodyapps', name: 'kodyapps.dev' }], }), ) + .mockResolvedValueOnce( + Response.json({ + success: true, + result: [ + { + id: 'dns-existing-apex', + type: 'AAAA', + name: 'kodyapps.dev', + content: '100::', + proxied: true, + }, + ], + }), + ) .mockResolvedValueOnce( Response.json({ success: true, @@ -1053,7 +1094,7 @@ test('ensurePackageAppWildcardDnsRecord reuses an existing proxied wildcard reco }), ) - await ensurePackageAppWildcardDnsRecord({ + await ensurePackageAppDnsRecords({ accountId: 'account-1', apiToken: 'token-1', packageAppHostname: 'kodyapps.dev', @@ -1061,7 +1102,7 @@ test('ensurePackageAppWildcardDnsRecord reuses an existing proxied wildcard reco fetcher, }) - expect(fetcher).toHaveBeenCalledTimes(2) + expect(fetcher).toHaveBeenCalledTimes(3) }) test('writeGeneratedWranglerConfig rejects invalid environment asset config', async () => { diff --git a/tools/ci/resource-utils.ts b/tools/ci/resource-utils.ts index 627d70ec5..b26ae46f0 100644 --- a/tools/ci/resource-utils.ts +++ b/tools/ci/resource-utils.ts @@ -494,6 +494,10 @@ export function readPackageAppZoneName(packageAppHostname: string) { return parsed.domain ?? null } +export function packageAppApexRoutePattern(packageAppHostname: string) { + return `${packageAppHostname}/*` +} + export function packageAppWildcardRoutePattern(packageAppHostname: string) { return `*.${packageAppHostname}/*` } @@ -502,6 +506,21 @@ export function packageAppWildcardDnsRecordName(packageAppHostname: string) { return `*.${packageAppHostname}` } +/** + * DNS record names the package-app zone needs: the apex and the per-user + * wildcard. Both are served by zone routes, which do not create DNS records, + * so both need a proxied placeholder record. Neither may be a Workers custom + * domain: a custom domain in a zone whose route table the deploy also + * publishes gets detached (and its DNS record deleted) when the routes are + * replaced — this took the package-app apex down on 2026-08-11. + */ +export function packageAppDnsRecordNames(packageAppHostname: string) { + return [ + packageAppHostname, + packageAppWildcardDnsRecordName(packageAppHostname), + ] +} + async function lookupCloudflareZoneId(input: { accountId: string apiToken: string @@ -522,19 +541,24 @@ async function lookupCloudflareZoneId(input: { return zone?.id ?? null } -function isPackageAppWildcardDnsRecord(input: { +function isRequiredPackageAppDnsRecord(input: { record: CloudflareDnsRecord - wildcardRecordName: string + recordName: string }) { return ( input.record.type === 'AAAA' && - input.record.name === input.wildcardRecordName && + input.record.name === input.recordName && input.record.content === packageAppWildcardDnsContent && input.record.proxied === true ) } -export async function ensurePackageAppWildcardDnsRecord(input: { +/** + * Idempotently ensure the proxied placeholder AAAA records the package-app + * zone routes need: one at the apex and one at the per-user wildcard (see + * `packageAppDnsRecordNames` for why neither may be a custom domain). + */ +export async function ensurePackageAppDnsRecords(input: { accountId: string apiToken: string packageAppHostname: string @@ -549,13 +573,13 @@ export async function ensurePackageAppWildcardDnsRecord(input: { `Could not derive a registrable zone name from PACKAGE_APP_BASE_URL host "${input.packageAppHostname}". Use a hostname on a public suffix (for example kodyapps.dev).`, ) } - const wildcardRecordName = packageAppWildcardDnsRecordName( - input.packageAppHostname, - ) + const recordNames = packageAppDnsRecordNames(input.packageAppHostname) if (input.dryRun) { - console.error( - `[dry-run] ensure package-app wildcard DNS: ${wildcardRecordName} AAAA ${packageAppWildcardDnsContent} (proxied) in zone ${zoneName}`, - ) + for (const recordName of recordNames) { + console.error( + `[dry-run] ensure package-app DNS: ${recordName} AAAA ${packageAppWildcardDnsContent} (proxied) in zone ${zoneName}`, + ) + } return } @@ -569,62 +593,62 @@ export async function ensurePackageAppWildcardDnsRecord(input: { }) if (!zoneId) { return fail( - `Package-app zone "${zoneName}" was not found in Cloudflare account ${input.accountId}. Create the zone, add a proxied wildcard DNS record (${wildcardRecordName} AAAA ${packageAppWildcardDnsContent}), then re-run deploy. See docs/contributing/setup-manifest.md.`, + `Package-app zone "${zoneName}" was not found in Cloudflare account ${input.accountId}. Create the zone, add proxied DNS records (${recordNames.join(' and ')} AAAA ${packageAppWildcardDnsContent}), then re-run deploy. See docs/contributing/setup-manifest.md.`, ) } - // List every record type at the wildcard name: filtering to AAAA would hide - // a conflicting A or CNAME record and turn the actionable conflict error - // below into an opaque create failure. - const listed = await cloudflareRootApiRequest>({ - apiToken: input.apiToken, - apiBaseUrl: input.apiBaseUrl, - fetcher: input.fetcher, - sleep: input.sleep, - pathname: `/zones/${encodeURIComponent(zoneId)}/dns_records?name=${encodeURIComponent(wildcardRecordName)}`, - }) - // Conflicts are checked before accepting the required record: a stray A, - // CNAME, or extra AAAA at the wildcard name must fail the deploy even when - // the proxied AAAA also exists, otherwise resolution stays ambiguous. - const conflicting = (listed.result ?? []).find( - (record) => - record.name === wildcardRecordName && - !isPackageAppWildcardDnsRecord({ record, wildcardRecordName }), - ) - if (conflicting) { - return fail( - `Package-app wildcard DNS record "${wildcardRecordName}" exists in zone "${zoneName}" but is not a proxied AAAA ${packageAppWildcardDnsContent} record (found ${conflicting.type} ${conflicting.content}, proxied=${String(conflicting.proxied)}). Fix it in the Cloudflare dashboard, then re-run deploy.`, + for (const recordName of recordNames) { + // List every record type at the name: filtering to AAAA would hide a + // conflicting A or CNAME record and turn the actionable conflict error + // below into an opaque create failure. + const listed = await cloudflareRootApiRequest>({ + apiToken: input.apiToken, + apiBaseUrl: input.apiBaseUrl, + fetcher: input.fetcher, + sleep: input.sleep, + pathname: `/zones/${encodeURIComponent(zoneId)}/dns_records?name=${encodeURIComponent(recordName)}`, + }) + // Conflicts are checked before accepting the required record: a stray A, + // CNAME, or extra AAAA at the name must fail the deploy even when the + // proxied AAAA also exists, otherwise resolution stays ambiguous. + const conflicting = (listed.result ?? []).find( + (record) => + record.name === recordName && + !isRequiredPackageAppDnsRecord({ record, recordName }), ) - } + if (conflicting) { + return fail( + `Package-app DNS record "${recordName}" exists in zone "${zoneName}" but is not a proxied AAAA ${packageAppWildcardDnsContent} record (found ${conflicting.type} ${conflicting.content}, proxied=${String(conflicting.proxied)}). Fix it in the Cloudflare dashboard, then re-run deploy.`, + ) + } - const existing = (listed.result ?? []).find((record) => - isPackageAppWildcardDnsRecord({ record, wildcardRecordName }), - ) - if (existing) { + const existing = (listed.result ?? []).find((record) => + isRequiredPackageAppDnsRecord({ record, recordName }), + ) + if (existing) { + console.error(`Package-app DNS exists: ${existing.name} (${existing.id})`) + continue + } + + const created = await cloudflareRootApiRequest({ + apiToken: input.apiToken, + apiBaseUrl: input.apiBaseUrl, + fetcher: input.fetcher, + sleep: input.sleep, + pathname: `/zones/${encodeURIComponent(zoneId)}/dns_records`, + method: 'POST', + body: { + type: 'AAAA', + name: recordName, + content: packageAppWildcardDnsContent, + proxied: true, + ttl: 1, + }, + }) console.error( - `Package-app wildcard DNS exists: ${existing.name} (${existing.id})`, + `Created package-app DNS: ${created.result?.name} (${created.result?.id})`, ) - return } - - const created = await cloudflareRootApiRequest({ - apiToken: input.apiToken, - apiBaseUrl: input.apiBaseUrl, - fetcher: input.fetcher, - sleep: input.sleep, - pathname: `/zones/${encodeURIComponent(zoneId)}/dns_records`, - method: 'POST', - body: { - type: 'AAAA', - name: wildcardRecordName, - content: packageAppWildcardDnsContent, - proxied: true, - ttl: 1, - }, - }) - console.error( - `Created package-app wildcard DNS: ${created.result?.name} (${created.result?.id})`, - ) } export async function listCloudflareQueues(input: { @@ -1283,11 +1307,13 @@ function readLegacyHostsVar(input: { * the first deploy after the flip detaches the old origin and deletes its DNS. * * Per-user hosted package apps use `{username}.` subdomains. - * Cloudflare **custom domains** cannot be wildcards, so the apex - * (`PACKAGE_APP_BASE_URL`) stays a `custom_domain` route for legacy redirects, - * while `*./*` is published as a **zone route** (`zone_name` - * is the registrable domain). Zone routes do not create DNS records — production - * CI ensures a proxied wildcard AAAA `100::` record exists separately. + * Both the apex (`PACKAGE_APP_BASE_URL`, serving legacy redirects) and + * `*./*` are published as **zone routes** (`zone_name` is the + * registrable domain) on the runtime Worker — never as custom domains: a custom + * domain in a zone whose route table the deploy also publishes gets detached + * (deleting its DNS record) when the routes are replaced, which took the + * package-app apex down on 2026-08-11. Zone routes do not create DNS records — + * production CI ensures proxied AAAA `100::` records for both names separately. * * The routes are generated instead of committed because Wrangler resolves **local * dev** request URLs against the first configured route: a committed diff --git a/tools/ci/runtime-worker-config.node.test.ts b/tools/ci/runtime-worker-config.node.test.ts index 01cee9979..026b0d415 100644 --- a/tools/ci/runtime-worker-config.node.test.ts +++ b/tools/ci/runtime-worker-config.node.test.ts @@ -246,7 +246,11 @@ test('generate publishes the package-app custom domain for production', async () const runtimeConfig = parseJsonc<{ env?: { production?: { - routes?: Array<{ pattern: string; custom_domain?: boolean }> + routes?: Array<{ + pattern: string + custom_domain?: boolean + zone_name?: string + }> workers_dev?: boolean migrations?: unknown } @@ -254,8 +258,11 @@ test('generate publishes the package-app custom domain for production', async () migrations?: Array<{ tag?: string; transferred_classes?: unknown }> }>(await readFile(outConfigPath, 'utf8')) + // Both package-app routes are zone routes, never custom domains: a + // custom domain in a zone whose route table the deploy also publishes + // gets detached (deleting its DNS record) when the routes are replaced. expect(runtimeConfig.env?.production?.routes).toEqual([ - { pattern: 'kodyapps.dev', custom_domain: true }, + { pattern: 'kodyapps.dev/*', zone_name: 'kodyapps.dev' }, { pattern: '*.kodyapps.dev/*', zone_name: 'kodyapps.dev' }, ]) expect(runtimeConfig.env?.production?.workers_dev).toBe(true) diff --git a/tools/ci/runtime-worker-config.ts b/tools/ci/runtime-worker-config.ts index 65c7da840..e6476e86b 100644 --- a/tools/ci/runtime-worker-config.ts +++ b/tools/ci/runtime-worker-config.ts @@ -1,5 +1,6 @@ import { readFile, writeFile } from 'node:fs/promises' import { + packageAppApexRoutePattern, packageAppWildcardRoutePattern, parseJsonc, readPackageAppZoneName, @@ -187,11 +188,18 @@ function copyResourceIdentifiers(input: { } /** - * Attach the package-app origin as this Worker's custom domain, plus the - * per-user subdomain wildcard zone route (decision 0017; Cloudflare custom - * domains cannot be wildcards). The main Worker's generated config no longer - * lists the package-app host (see tools/ci/resource-utils.ts), so the - * runtime Worker owns both routes. + * Attach the package-app apex and the per-user subdomain wildcard as **zone + * routes** on this Worker (decision 0017). The main Worker's generated config + * no longer lists the package-app host (see tools/ci/resource-utils.ts), so + * the runtime Worker owns both routes. + * + * Neither may be a Cloudflare custom domain. Wildcards cannot be custom + * domains at all, and a custom domain must not coexist with published zone + * routes in the same zone: replacing that zone's route table during deploy + * detaches the custom domain and deletes its DNS record (this took + * `kodyapps.dev` down on 2026-08-11). Both hostnames are instead served by + * proxied placeholder DNS records that production CI ensures separately + * (zone routes do not create DNS records). */ function addPackageAppRoute(runtimeEnv: JsonRecord, envName: string) { const vars = @@ -216,6 +224,7 @@ function addPackageAppRoute(runtimeEnv: JsonRecord, envName: string) { `runtime env.${envName}.vars.PACKAGE_APP_BASE_URL host "${hostname}" has no registrable zone name. Use a hostname on a public suffix (for example kodyapps.dev).`, ) } + const apexPattern = packageAppApexRoutePattern(hostname) const wildcardPattern = packageAppWildcardRoutePattern(hostname) const existingRoutes = Array.isArray(runtimeEnv.routes) ? runtimeEnv.routes.filter( @@ -223,12 +232,13 @@ function addPackageAppRoute(runtimeEnv: JsonRecord, envName: string) { !route || typeof route !== 'object' || ((route as JsonRecord).pattern !== hostname && + (route as JsonRecord).pattern !== apexPattern && (route as JsonRecord).pattern !== wildcardPattern), ) : [] runtimeEnv.routes = [ ...existingRoutes, - { pattern: hostname, custom_domain: true }, + { pattern: apexPattern, zone_name: zoneName }, { pattern: wildcardPattern, zone_name: zoneName }, ] // Keep the workers.dev trigger as the deploy healthcheck target and a