feat(oauth): 账号名改从 zone.account 取,去掉 account-settings.read 依赖

实测确认 GET /zones 每个 zone 自带 account {id,name},该接口只需 zone.read,
因此账号 id 与账号名都不必再调 GET /accounts(account-settings.read)。
顺带修掉 account_id 旧字段在当前响应里已为 null 导致的降级路径取不到账号 id。

- RealCFApi.discover 只列 /zones,移除 listAccounts 与 accountSource
- scope 候选集去掉 account-settings.read;设置页移除「含账号名权限」按钮
- 设置页 Scope 说明与 docs/02、README 同步(补 User Details Read 的区别说明)
- 85/85 测试通过(新增 zone 带 account / 不带 account 两种断言)
This commit is contained in:
bigcode
2026-09-30 17:03:59 +08:00
parent 7a1625d575
commit 5b887908e2
8 changed files with 82 additions and 88 deletions
-4
View File
@@ -337,10 +337,6 @@ export interface OAuthPreflightResult {
/** 本应用默认需要的 scope(点号分隔)——与后端 CF_OAUTH_SCOPES_DEFAULT 保持一致 */
export const CF_OAUTH_SCOPES_DEFAULT = "zone.read workers-scripts.write email-routing-rule.write";
/** 可选:勾了 Account Settings Read 才能显示真实账号名,否则用第一个域名代替 */
export const CF_OAUTH_SCOPES_WITH_ACCOUNT_NAME =
"account-settings.read zone.read workers-scripts.write email-routing-rule.write";
/** 授权配置自检:探测 Cloudflare 授权端点是否接受当前配置(不产生授权) */
export const oauthPreflight = () => api<OAuthPreflightResult>("/api/oauth/cloudflare/preflight");
@@ -38,7 +38,6 @@ import {
oauthPreflight,
putSettings,
CF_OAUTH_SCOPES_DEFAULT,
CF_OAUTH_SCOPES_WITH_ACCOUNT_NAME,
type OAuthPreflightResult,
type SettingsInfo,
} from "../api.ts";
@@ -391,8 +390,7 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
<>
Cloudflare 的 OAuth scope 是<b>点号分隔</b>(zone.read),冒号写法(zone:read)是 wrangler 的
第一方简写,第三方客户端会被判 invalid_scope。必须与 OAuth App 里勾选的权限一致;检测按钮会
直接告诉你哪些已授权、哪些还要补勾。<b>account-settings.read 是可选的</b>:勾了才显示真实账号名,
不勾则用第一个域名代替,不影响接入。
直接告诉你哪些已授权、哪些还要补勾。三项就够:账号名随 zone 一起返回,不需要账号级权限。
</>
}
>
@@ -449,15 +447,6 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
>
填入最小 scope
</Button>
<Button
size="small"
onClick={() => {
form.setFieldValue("scope", CF_OAUTH_SCOPES_WITH_ACCOUNT_NAME);
message.success("已填入含账号名权限的 scope(需 App 里也勾了 Account Settings Read)");
}}
>
含账号名权限
</Button>
{check.scopes.allowed.length > 0 && (
<Button
size="small"
+15 -27
View File
@@ -32,8 +32,8 @@ export const CF_OAUTH_CALLBACK_PATH = "/api/oauth/cloudflare/callback";
* 重要背景(实测结论):
* - Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.<verb>`;
* `account:read` 这种冒号写法是 **wrangler 的第一方简写**,第三方客户端请求会被直接拒。
* - 「账号」不是 `account.read` 而是 `account-settings.read`(选择器里叫 Account Settings Read);
它只用来取账号名,**默认不请求** —— 缺它时 discover() 会从 zone 的 account_id 反推账户。
* - 账号名不需要额外的账号级权限:`GET /zones` 每个 zone 自带 `account: {id, name}`,
* 该接口只要求 Zone Read,所以本应用完全用不到 Account Settings Read。
* - Email Routing 在选择器里是 **四个独立权限**,其中改 catch-all 规则对应
* `email-routing-rule.write`(选择器里叫 Email Routing Rules Edit)。
* - 客户端注册的 scope 是「上限」而不是「默认值」:请求里没写的 scope 不会自动生效。
@@ -48,7 +48,6 @@ export const CF_OAUTH_SCOPES_DEFAULT = [
/** # 成员变量: scope 探测候选集(本应用所需 + 常见备选)
* 探测时逐个试:被 Cloudflare 点名拒绝的剔除,剩下的即为 App 已授权的集合。 **/
export const CF_OAUTH_SCOPE_CANDIDATES = [
"account-settings.read",
"zone.read",
"workers-scripts.write",
"email-routing-rule.write",
@@ -351,6 +350,8 @@ export interface CFZone {
name: string;
account_id: string;
status: string;
/** zone 所属账户(Cloudflare 在 GET /zones 的每个 zone 上直接附带,无需账号级读权限) **/
account?: { id: string; name?: string };
}
/** # 成员变量: 账号/域名发现结果 **/
@@ -359,8 +360,6 @@ export interface CFDiscovery {
account: CFAccount;
/** 该账户(或 token 可见)下的域名 Zone */
zones: CFZone[];
/** 账号来源:accounts 接口,或因缺少账号级读权限而从 zone 的 account_id 反推 */
accountSource: "accounts" | "zones";
}
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
@@ -437,16 +436,11 @@ export class RealCFApi implements CFApi {
return data.result;
}
listAccounts(token: string) {
return this.api<CFAccount[]>(token, "/accounts");
}
async listZones(token: string, accountId?: string) {
// 分页拉全 Zone(每页 50,最多 20 页);不给 accountId 时返回 token 可见的全部 Zone
async listZones(token: string) {
// 分页拉全 Zone(每页 50,最多 20 页):token 可见的全部 Zone
const out: CFZone[] = [];
for (let page = 1; page <= 20; page++) {
const query = new URLSearchParams({ per_page: "50", page: String(page) });
if (accountId) query.set("account.id", accountId);
const rows = await this.api<CFZone[]>(token, `/zones?${query.toString()}`);
out.push(...rows);
if (rows.length < 50) break;
@@ -455,24 +449,18 @@ export class RealCFApi implements CFApi {
}
/** 账号 + 域名发现。
* 优先用 /accounts 拿账户名(需要账号级读权限);
* 没有该权限时退而求其次:直接列 token 可见的 Zone,用 zone 自带的 account_id 反推账户,
* 展示名用第一个域名代替 —— 这样只要授权了 zone.read 就能完成接入。 */
* 账号完全由 zone 推导:`GET /zones` 的每个 zone 自带 `account: {id, name}`,
* 所以只要 zone.read 就能同时拿到域名和真实账号名,不需要任何账号级读权限
* (也就无需 Account Settings Read)。zone 上没带 account 时用第一个域名兜底展示。 */
async discover(token: string): Promise<CFDiscovery> {
try {
const accounts = await this.listAccounts(token);
if (accounts.length > 0) {
const account = accounts[0];
return { account, zones: await this.listZones(token, account.id), accountSource: "accounts" };
}
} catch {
// 缺少账号级读权限,走下面的反推路径
}
const zones = await this.listZones(token);
if (zones.length === 0) throw new Error("no cloudflare zone accessible with this token");
const accountId = zones[0].account_id;
const more = zones.length > 1 ? ` 等 ${zones.length} 个域名` : "";
return { account: { id: accountId, name: `${zones[0].name}${more}` }, zones, accountSource: "zones" };
const first = zones[0];
const fallback = `${first.name}${zones.length > 1 ? ` 等 ${zones.length} 个域名` : ""}`;
return {
account: { id: first.account?.id ?? first.account_id, name: first.account?.name || fallback },
zones,
};
}
async putEmailWorker(opts: {