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
2 changes: 1 addition & 1 deletion .github/workflows/deploy-website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ jobs:

- name: Verify Cloudflare authentication
if: ${{ env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != '' }}
run: pnpm dlx wrangler@4.118.0 whoami
run: node website/scripts/check-pages-auth.mjs

- name: Deploy to Cloudflare Pages
if: ${{ env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != '' }}
Expand Down
2 changes: 1 addition & 1 deletion website/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ website/dist
2. 构建 Astro 静态站。
3. 执行 `pnpm check:website`。
4. 执行 Astro 类型检查、七语构建页面检查和下载逻辑测试;PR 阶段也执行相同门禁。
5. 使用 `wrangler whoami` 验证部署授权,再通过 Direct Upload 部署到 Pages 项目 `goagent`。
5. 通过 Cloudflare API 读取当前账号下的 `goagent` Pages 项目以验证授权,再通过 Direct Upload 部署。此检查不依赖 `wrangler whoami` 的账号列表权限,不为部署扩大 Token 权限;授权失败仍会阻止上传。

Cloudflare Pages 项目当前是 Direct Upload 项目,不依赖 Pages 控制台的 Git 绑定。自动部署需要 GitHub 仓库 secrets:

Expand Down
34 changes: 34 additions & 0 deletions website/scripts/check-pages-auth.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { pathToFileURL } from 'node:url'

export async function checkPagesAuth({ accountId, token, fetcher = fetch }) {
if (!/^[a-f0-9]{32}$/i.test(accountId || '') || !token?.trim()) {
throw new Error('Cloudflare deployment credentials are missing or malformed.')
}
let response
let payload
try {
response = await fetcher(`https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/goagent`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
redirect: 'error',
signal: AbortSignal.timeout(15000)
})
payload = await response.json()
} catch {
// Never surface raw network errors or API bodies which can contain account information.
throw new Error('Cloudflare Pages authorization check could not be completed.')
}
if (!response.ok || payload.success !== true || payload.result?.name !== 'goagent') {
throw new Error(`Cloudflare Pages authorization failed (HTTP ${response.status}). Check the existing project-scoped deployment token.`)
}
return true
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
await checkPagesAuth({ accountId: process.env.CLOUDFLARE_ACCOUNT_ID, token: process.env.CLOUDFLARE_API_TOKEN })
console.log('Cloudflare Pages authorization verified for goagent.')
} catch (error) {
console.error(error.message)
process.exitCode = 1
}
}
33 changes: 33 additions & 0 deletions website/tests/pages-auth.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { checkPagesAuth } from '../scripts/check-pages-auth.mjs'

const credentials = { accountId: 'a'.repeat(32), token: 'test-only-token' }

test('Pages authorization uses only the configured account and existing project', async () => {
let requests = 0
assert.equal(await checkPagesAuth({ ...credentials, fetcher: async (url, options) => {
requests++
assert.equal(url, `https://api.cloudflare.com/client/v4/accounts/${credentials.accountId}/pages/projects/goagent`)
assert.equal(options.headers.Authorization, 'Bearer test-only-token')
assert.equal(options.redirect, 'error')
assert.ok(options.signal instanceof AbortSignal)
return { ok: true, status: 200, json: async () => ({ success: true, result: { name: 'goagent' } }) }
} }), true)
assert.equal(requests, 1)
})

test('Pages authorization rejects missing credentials before any request', async () => {
await assert.rejects(checkPagesAuth({ accountId: '', token: '', fetcher: () => assert.fail('must not call') }), /missing or malformed/)
})

test('Pages authorization rejects denied, malformed and wrong-project responses', async () => {
for (const [status, payload] of [[403, { success: false }], [200, { success: false }], [200, { success: true, result: { name: 'another-project' } }]]) {
await assert.rejects(checkPagesAuth({ ...credentials, fetcher: async () => ({ ok: status === 200, status, json: async () => payload }) }), /authorization failed/)
}
})

test('Pages authorization does not expose raw network errors or response bodies', async () => {
await assert.rejects(checkPagesAuth({ ...credentials, fetcher: async () => { throw new Error(credentials.token) } }), error => !error.message.includes(credentials.token))
await assert.rejects(checkPagesAuth({ ...credentials, fetcher: async () => ({ ok: false, status: 401, json: async () => ({ success: false, errors: [credentials.token] }) }) }), error => !error.message.includes(credentials.token))
})
Loading