Files
zhu1090093659 ec81b5526e feat(market): measure the deployed assets inside the worker
The deploy lane proves that the version it published serves every path its
manifests advertise, but the public origin refuses part of every sweep sent
from a GitHub runner range: six consecutive runs reported the same ~150 of
3075 paths, a serial re-check recovered none of them after a three-minute
probe, and those exact paths answer 200 with the committed byte lengths from a
residential network, from two public cloud fetchers and from a local sweep.
The lane can read that only as a policy on the vantage, which leaves the
deployed artifacts unverified from CI.

POST /api/asset-attest moves the measurement inside Cloudflare: a caller
posts a window of up to 500 site-relative paths and reads back the byte
length the deployed version serves for each one out of its own ASSETS
binding. It is a read-only measurement over already-public assets and stays
out of the client-facing surface; one call still causes up to that many
internal asset fetches, so it fails closed behind a shared secret (503 when
the binding is unset, 403 when the secret is wrong, neither touching the
assets).

market-verify-assets.mjs keeps --origin for manual runs from a network the
edge allows, with its transient retry, serial re-check and all-403 refusal
notice, and the deploy lane verifies through the new route instead.
deploy-market writes the binding from MARKET_ATTEST_SECRET and fails loudly
when it is missing, because the verification step fails closed without it.
2026-09-24 08:43:50 +08:00

148 lines
6.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
'use strict'
/**
* deploy-market — deploy the dsh-market.com Worker static site and apply D1
* migrations.
*
* Pipeline of this script:
* 1. sanity: market-build --check (committed dist is up to date)
* 2. wrangler d1 migrations apply dsh-market --remote (idempotent)
* 3. wrangler deploy (worker + static assets + custom domain dsh-market.com)
* 4. ensure the worker actually has a TURNSTILE_SECRET binding (refreshing
* it from the deploy environment when present) — likes/installs fail
* closed without it, so a missing secret fails the deploy loudly
*
* Auth: wrangler uses its own login/CI env (CLOUDFLARE_API_TOKEN,
* CLOUDFLARE_ACCOUNT_ID).
*
* Usage:
* node scripts/deploy-market --wrangler "npx wrangler@4" # custom wrangler cmd
*/
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const ROOT = path.resolve(__dirname, '..')
const WORKER_DIR = path.join(ROOT, 'market', 'worker')
const D1_NAME = 'dsh-market'
function opt(name, fallback) {
const i = process.argv.indexOf(name)
return i >= 0 && process.argv[i + 1] !== undefined ? process.argv[i + 1] : fallback
}
const wranglerCmd = opt('--wrangler', 'wrangler')
function fail(msg) {
console.error('deploy-market: ' + msg)
process.exit(1)
}
function sh(cmd, args, env, opts) {
const input = opts && opts.input
// When input is given, stdin must be a pipe: with stdio 'inherit' node
// silently drops the payload and the child reads an empty stream — which
// once stored an empty TURNSTILE_SECRET while wrangler reported Success.
const stdio = opts && opts.capture
? 'pipe'
: input !== undefined ? ['pipe', 'inherit', 'inherit'] : 'inherit'
const r = spawnSync(cmd, args, {
cwd: WORKER_DIR,
env: Object.assign({}, process.env, env || {}),
stdio,
encoding: opts && opts.capture ? 'utf8' : undefined,
input,
})
if (r.status !== 0) fail(cmd + ' ' + args.join(' ') + ' failed (exit ' + r.status + ')')
return r
}
const [cmdExec, ...cmdArgs] = wranglerCmd.split(' ')
const wr = (args, env, opts) => sh(cmdExec, [...cmdArgs, ...args], env, opts)
function secretNames() {
const list = wr(['secret', 'list'], undefined, { capture: true })
return String(list.stdout || '') + String(list.stderr || '')
}
// Turnstile fail-closed contract: /api/like and /api/install reject every
// write when the worker has no TURNSTILE_SECRET, so deploying without the
// secret silently disables likes/installs. Refresh the binding when the
// deploy environment carries it, then verify the worker actually has it —
// a missing secret must fail the deploy loudly, not ship an ungated (old
// code) or write-dead (new code) production API.
function ensureTurnstileSecret() {
// Trim before putting: a repo secret pasted with a trailing newline once
// shipped a value siteverify rejected (invalid-input-secret) and took the
// like/install gate down site-wide while every deploy re-put the same bytes.
const secret = (process.env.TURNSTILE_SECRET || '').trim()
if (secret) {
console.log('deploy-market: refreshing TURNSTILE_SECRET binding')
wr(['secret', 'put', 'TURNSTILE_SECRET'], undefined, { input: secret })
}
if (!secretNames().includes('TURNSTILE_SECRET')) {
fail('worker has no TURNSTILE_SECRET binding — set it (wrangler secret put TURNSTILE_SECRET --config market/worker/wrangler.jsonc) or export TURNSTILE_SECRET before deploying; likes/installs fail closed without it')
}
assertTurnstilePairing(secret)
}
// Attestation fail-closed contract: the lane that verifies the deployed assets
// calls POST /api/asset-attest, which answers 503 without its secret binding,
// so a deploy that loses the binding leaves the post-deploy verification unable
// to measure anything. Refresh it when the deploy environment carries it, then
// verify the binding is there.
function ensureAttestSecret() {
const secret = (process.env.MARKET_ATTEST_SECRET || '').trim()
if (secret) {
console.log('deploy-market: refreshing ASSET_ATTEST_SECRET binding')
wr(['secret', 'put', 'ASSET_ATTEST_SECRET'], undefined, { input: secret })
}
if (!secretNames().includes('ASSET_ATTEST_SECRET')) {
fail('worker has no ASSET_ATTEST_SECRET binding — set it (wrangler secret put ASSET_ATTEST_SECRET --config market/worker/wrangler.jsonc) or export MARKET_ATTEST_SECRET before deploying; the attestation route fails closed without it')
}
}
// Probe siteverify with the just-put binding value and a dummy response token.
// A correctly paired secret answers invalid-input-response; invalid-input-secret
// means the binding does not match the widget and every like/install would 403.
function assertTurnstilePairing(secret) {
if (!secret) return
const probe = spawnSync('curl', [
'-s', '-X', 'POST', 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
'-d', 'secret=' + secret, '-d', 'response=probe-dummy-token',
], { encoding: 'utf8' })
let codes = []
try { codes = JSON.parse(probe.stdout || '{}')['error-codes'] || [] } catch { /* unparsable body */ }
if (codes.includes('invalid-input-secret')) {
fail('TURNSTILE_SECRET does not pair with the dsh-market widget sitekey — likes/installs would 403 site-wide. Read the current secret from GET /accounts/{account}/challenges/widgets/0x4AAAAAAEYeoSRJRjgCOiZI and update the repository secret.')
}
console.log('deploy-market: Turnstile binding pairing check ' + (codes.length ? 'saw ' + codes.join(',') : 'unavailable (treating as pass)'))
}
async function main() {
// 1) 产物一致性(CI 只部署已提交产物)
const check = spawnSync('node', [path.join(ROOT, 'scripts', 'market-build'), '--check'], {
cwd: ROOT, stdio: 'inherit',
})
if (check.status !== 0) fail('market-build --check failed — rebuild and commit market/dist first')
// 2) D1 migration(幂等)
wr(['d1', 'migrations', 'apply', D1_NAME, '--remote'])
// 3) Worker 部署(含静态资产与自定义域名)
wr(['deploy'])
// 4) Turnstile 绑定核验(worker 缺绑定时点赞/安装全部 fail closed)
ensureTurnstileSecret()
// 5) 资产校验密钥绑定核验(缺绑定时部署后校验路由 fail closed,等于没校验)
ensureAttestSecret()
console.log('deploy-market: done — https://dsh-market.com')
}
main().catch((err) => {
fail(err && err.message ? err.message : String(err))
})