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
13 changes: 10 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,10 @@ jobs:
echo "SENTRY_DSN is not configured; skipping jobs worker secret sync."
exit 0
fi
node tools/ci/sync-worker-secrets.ts --env production --config \
# --env "" with --name: wrangler secret bulk still suffixes
# -<env> even when --name is set, so --env production would write
# to kody-jobs-production while deploy uploads kody-jobs.
node tools/ci/sync-worker-secrets.ts --env "" --config \
"$WRANGLER_CONFIG" --name kody-jobs --set-from-env-optional SENTRY_DSN

- name: ☁️ Deploy jobs worker
Expand Down Expand Up @@ -343,6 +346,10 @@ jobs:
# (Cloudflare REST capabilities). Billing (Stripe), waiting-list (Kit),
# OAuth client credentials, and maintenance secrets stay main-only so a
# runtime-Worker compromise cannot reach them.
# --env "" with --name: wrangler secret bulk still suffixes -<env>
# even when --name is set, so --env production would write
# COOKIE_SECRET to kody-runtime-production while deploy uploads
# kody-runtime. Preview uses the same empty-env pin.
- name: 🔐 Sync Cloudflare Secrets to runtime worker (bulk)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Expand All @@ -353,8 +360,8 @@ jobs:
AI_GATEWAY_ID: ${{ secrets.AI_GATEWAY_ID }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: >
node tools/ci/sync-worker-secrets.ts --env production --name
kody-runtime --config "$WRANGLER_CONFIG" --set-from-env COOKIE_SECRET
node tools/ci/sync-worker-secrets.ts --env "" --name kody-runtime
--config "$WRANGLER_CONFIG" --set-from-env COOKIE_SECRET
--set-from-env-optional SECRET_STORE_KEY --set-from-env-optional
AI_GATEWAY_ID --set-from-env-optional SENTRY_DSN
--set-from-env-optional CLOUDFLARE_API_TOKEN
Expand Down
19 changes: 11 additions & 8 deletions docs/contributing/architecture/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,14 +391,17 @@ carries the parameter is rewritten without it before package code sees it.

Mint and consume must share `COOKIE_SECRET`. In production the app-origin mint
(`/@{username}/packages/...`) is forwarded to `kody-runtime`, and the subdomain
exchange is served by that same script's zone routes. If a leftover main-worker
route still receives `{username}.kodyapps.dev`, the main Worker must forward it
too (`isRuntimeWorkerOwnedRequest` matches every package-app host, not only the
apex). A `COOKIE_SECRET` mismatch, or a missing secret swallowed as "invalid
token", leaves the visitor on the 403 page with `__kody_handoff` still in the
URL and no `Set-Cookie`. Missing `COOKIE_SECRET` on consume fails closed with
500; signature / expiry / path / replay rejects log a reason without the token
and set `X-Kody-Handoff: rejected`.
exchange is served by that same script's zone routes. Production CI therefore
syncs `COOKIE_SECRET` onto the unsuffixed `kody-runtime` script (`--env ""` plus
`--name`); `wrangler secret bulk --env production --name kody-runtime` still
writes `kody-runtime-production`, which the runtime deploy does not serve. If a
leftover main-worker route still receives `{username}.kodyapps.dev`, the main
Worker must forward it too (`isRuntimeWorkerOwnedRequest` matches every
package-app host, not only the apex). A `COOKIE_SECRET` mismatch, or a missing
secret swallowed as "invalid token", leaves the visitor on the 403 page with
`__kody_handoff` still in the URL and no `Set-Cookie`. Missing `COOKIE_SECRET`
on consume fails closed with 500; signature / expiry / path / replay rejects log
a reason without the token and set `X-Kody-Handoff: rejected`.

**Package-app session cookie**
(`packages/worker/src/app/package-app-session.ts`). Exchanging a valid token on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,15 @@ node tools/ci/runtime-worker-config.ts generate \
--out-config packages/runtime-worker/wrangler-production.generated.json

# Sync secrets to both workers (full secret set to main; runtime-lane
# allowlist to kody-runtime — see deploy.yml for the current lists):
# allowlist to kody-runtime — see deploy.yml for the current lists).
# Runtime uses --env "" --name so wrangler does not write
# kody-runtime-production (secret bulk still suffixes -<env> even with
# --name; deploy uses --name to override).
node tools/ci/sync-worker-secrets.ts --env production \
--config packages/worker/wrangler-production.generated.json \
--set-from-env COOKIE_SECRET ... # copy the main-worker flag list from deploy.yml
node tools/ci/sync-worker-secrets.ts --env production \
node tools/ci/sync-worker-secrets.ts --env "" \
--name kody-runtime \
--config packages/runtime-worker/wrangler-production.generated.json \
--set-from-env COOKIE_SECRET \
--set-from-env-optional SECRET_STORE_KEY \
Expand Down
4 changes: 4 additions & 0 deletions tools/ci/runtime-worker-config.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ test('generate rewrites worker names, copies resource ids, and writes a bootstra
name?: string
env?: {
preview?: {
name?: string
durable_objects?: { bindings?: Array<Record<string, unknown>> }
d1_databases?: Array<Record<string, unknown>>
queues?: { producers?: Array<Record<string, unknown>> }
Expand All @@ -154,6 +155,7 @@ test('generate rewrites worker names, copies resource ids, and writes a bootstra
}>(await readFile(outConfigPath, 'utf8'))

expect(runtimeConfig.name).toBe('kody-pr-7-runtime')
expect(runtimeConfig.env?.preview?.name).toBe('kody-pr-7-runtime')
const previewEnv = runtimeConfig.env?.preview
// Cross-script references point at the resolved main worker name.
const userMeter = previewEnv?.durable_objects?.bindings?.find(
Expand Down Expand Up @@ -246,6 +248,7 @@ test('generate publishes the package-app custom domain for production', async ()
const runtimeConfig = parseJsonc<{
env?: {
production?: {
name?: string
routes?: Array<{
pattern: string
custom_domain?: boolean
Expand All @@ -265,6 +268,7 @@ test('generate publishes the package-app custom domain for production', async ()
{ pattern: 'kodyapps.dev/*', zone_name: 'kodyapps.dev' },
{ pattern: '*.kodyapps.dev/*', zone_name: 'kodyapps.dev' },
])
expect(runtimeConfig.env?.production?.name).toBe('kody-runtime')
expect(runtimeConfig.env?.production?.workers_dev).toBe(true)
// The storage transfer migration survives generation untouched.
expect(runtimeConfig.migrations?.[0]?.tag).toBe('v1')
Expand Down
4 changes: 4 additions & 0 deletions tools/ci/runtime-worker-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,10 @@ export async function generate(options: CliOptions) {
)

runtimeConfig.name = options.runtimeWorkerName
// Pin the selected env's name too. Wrangler otherwise deploys and
// secret-bulks `--env production` as `<name>-production`, which would not
// match the main worker's cross-script bindings (`kody-runtime`).
runtimeEnv.name = options.runtimeWorkerName
delete runtimeConfig.$schema
copyResourceIdentifiers({
runtimeEnv,
Expand Down
74 changes: 72 additions & 2 deletions tools/ci/sync-worker-secrets.node.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { expect, test } from 'vitest'
import { buildSpawnEnv } from './sync-worker-secrets'
import { expect, test, vi } from 'vitest'
import { consoleError } from '#worker/test-support/console-spies.ts'
import {
buildSpawnEnv,
buildWranglerSecretBulkFlags,
} from './sync-worker-secrets'

const baseOptions = {
env: undefined,
Expand Down Expand Up @@ -43,3 +47,69 @@ test('buildSpawnEnv preserves optional vars only when they have values', () => {
'https://examplePublicKey@o0.ingest.sentry.io/0',
)
})

test('secret bulk omits empty --env so --name pins the unsuffixed script', () => {
const secretsFile = '/tmp/wrangler-secrets.env'
expect(
buildWranglerSecretBulkFlags(
{
...baseOptions,
env: '',
name: 'kody-runtime',
config: 'packages/runtime-worker/wrangler-production.generated.json',
},
secretsFile,
),
).toEqual([
'secret',
'bulk',
secretsFile,
'--name',
'kody-runtime',
'--config',
'packages/runtime-worker/wrangler-production.generated.json',
])

expect(
buildWranglerSecretBulkFlags(
{
...baseOptions,
env: 'production',
config: 'packages/worker/wrangler-production.generated.json',
},
secretsFile,
),
).toEqual([
'secret',
'bulk',
secretsFile,
'--env',
'production',
'--config',
'packages/worker/wrangler-production.generated.json',
])
})

test('secret bulk rejects --env with --name so it cannot target name-env', () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called')
}) as never)
consoleError.mockImplementation(() => {})
try {
expect(() =>
buildWranglerSecretBulkFlags(
{
...baseOptions,
env: 'production',
name: 'kody-runtime',
},
'/tmp/wrangler-secrets.env',
),
).toThrow('process.exit called')
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('kody-runtime-production'),
)
} finally {
exitSpy.mockRestore()
}
})
42 changes: 31 additions & 11 deletions tools/ci/sync-worker-secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,28 +230,48 @@ function toDotenv(secrets: ReadonlyMap<string, string>) {
return `${lines.join('\n')}\n`
}

/**
* `wrangler secret bulk --env <env> --name <script>` still targets
* `<script>-<env>` (unlike `wrangler deploy --name`, which overrides the
* suffix). Callers that pin a script with `--name` must omit `--env`.
*/
export function buildWranglerSecretBulkFlags(
options: CliOptions,
secretsFilePath: string,
): Array<string> {
if (options.name && options.env && options.env.length > 0) {
fail(
`wrangler secret bulk appends -<env> even when --name is set, so "${options.name}" with --env ${options.env} would target "${options.name}-${options.env}". Pass --env "" with --name to pin the unsuffixed script.`,
)
}
const args = ['secret', 'bulk', secretsFilePath]
if (options.env !== undefined && options.env.length > 0) {
args.push('--env', options.env)
}
if (options.name) {
args.push('--name', options.name)
}
if (options.config) {
args.push('--config', options.config)
}
return args
}

async function runWranglerSecretBulk(options: CliOptions, dotenvText: string) {
const wranglerBin = resolveLocalBinary('wrangler')
const args = [wranglerBin, 'secret', 'bulk']
const spawnEnv = buildSpawnEnv(options)
const secretsFilePath = join(
tmpdir(),
`wrangler-secrets-${Date.now()}-${randomBytes(6).toString('hex')}.env`,
)
const args = [
wranglerBin,
...buildWranglerSecretBulkFlags(options, secretsFilePath),
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await writeFile(secretsFilePath, dotenvText, {
encoding: 'utf8',
mode: 0o600,
})
args.push(secretsFilePath)
if (options.env !== undefined) {
args.push('--env', options.env)
}
if (options.name) {
args.push('--name', options.name)
}
if (options.config) {
args.push('--config', options.config)
}

try {
const [command, ...commandArgs] = args
Expand Down
Loading