mirror of
https://github.com/zhu1090093659/dsh-web.git
synced 2026-09-28 14:24:03 +08:00
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.
148 lines
6.3 KiB
JavaScript
148 lines
6.3 KiB
JavaScript
#!/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))
|
||
})
|