Files
jkooe b3c16fb1eb feat(plan,history): 档位展示名与升档流程、历史窗口按档位截断、保留期换配置源(M2/M3/M7)
## 三件事

**M2 升档流程**:`/api/accounts/me` 下发顶层 `plan_name`(客户端只认它、缺失即显示
「未知档位」,此前服务端零下发 → 界面被迫显示机器键 `free`);新增
`GET /api/plans`(**台账驱动投影**:`PLAN_WIRING_STATE` 里 `unwired` 的字段一律不出现在
响应里)、`POST /api/plans/requests`、`GET /api/plans/requests/mine`;管理端三条
审批路由。`PLAN_TABLE.audit` 加档位判定 → 免费档 403 `E_PLAN_REQUIRED`(矛盾 17 裁决甲),
判定排在 `authzDevice` **之后**:先问"你有没有权看这台设备",再问"你的档位够不够",
否则会把"别人的设备"答成"你档位不够"。

**M3 历史窗口**:`/history` 下发 `allowed_windows: [{key,label}]`(**对象数组**,四个中文
标签只有这一条传输路径;写成裸键等于标签无来源);按档位截断并如实标注
`truncated` / `requested_window`,`bucket_sec` 跟**生效**窗口走而不是请求窗口;
400 文案改为固定文案(不回声 `req.query.window`、不念全量白名单)。
`team.history_days` 90→30。

**M7 保留期**:`retention_days` 改为读实例配置,不再有字面量。

## 一个真错误:`ok:true` 但档位没变

审批对**已驳回**的申请原来也返回 `ok:true, idempotent:true` —— 管理端看到成功回执,
而档位一点没变,且没有任何东西会告诉操作者他做错了。**回执与事实不一致比违反字面更坏**:
它没有下一次修正机会。现在区分两种"非 pending":

- `approved → approve` 是**真幂等**(同一结果,200,不重复写审计);
- `rejected → approve` 与 `approved → reject` 是**非法状态转移**,409 `E_REQUEST_NOT_PENDING`。

路由层原来把 `!r.ok` 一律映射成 404("这条申请不存在"),现在 404 与 409 分开:
否则操作者会以为挑错了行,而真实原因是那条申请已被驳回。

## 审计事件名:改的是 AC,不是代码

SPEC AC-09 原写 `plan_request_approved`,而 `API-CONTRACT` §8 与 `openapi.yaml` 写
`admin_plan_request_approved`。裁定采用带前缀的一侧,**依据不是"另一份文档那么写"**,
而是代码里已有 **12 个** `admin_*` 审计事件(`admin_plan_changed` / `admin_kicked` /
`admin_export` …):前缀让"管理端动作"这个集合可以按前缀一次扫出来;靠逐个记名字,
这个集合只能靠枚举 + 记忆,而枚举必漏。SPEC 已同步订正。

留一句方法论在那两个常量旁边:**差集只告诉你两边不一样,不告诉你哪边对。**

## 顺手清掉一份档位清单副本

`admin.js` 的 `setPlan` 硬编码 `['free','pro','team']`,改为 `Object.keys(PLAN_TABLE)`。
副本的害处不是"写错了",而是**它能跑**:将来加一档时这份列表不跟着变,新档位永远改不到,
而且不报错,只是校验说"档位不合法"。

## 测试

新增 `tests/plan-history-tier-test.js` **99 项**,已接入 `run-all.sh`。含三集同集断言
(响应键 / 台账非 unwired 键 / OpenAPI 属性集)与 **5 组负向对照**(多返回 export /
漏 audit / 台账种假字段 / OpenAPI 少写 / 多写),负向对照必须能红才有意义。

回归:permissions 60/0、admin-api 80/0、user-lifecycle 68/0、schema 一致性 116/0、
认领在场证明 57/0、配对安全 84/0。
2026-11-08 19:31:20 +08:00

519 lines
26 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.
/**
* 角色与能力矩阵 —— 唯一真相源(用户机制第一层)
*
* ── 为什么单独一个文件 ────────────────────────────────────────────
* "谁能做什么"会被三处使用:业务服务端点、网关订阅判定、测试。
* 本仓库已因"同一份清单写两处"翻车六次(指标字段 / msgpack / Dockerfile COPY /
* 配对码 / Dart 显示目录 / 配额数字),故这里把 **角色词汇、能力集合、能力所需
* 最低角色、档位配额** 全部收拢成一处,调用方一律引用。
*
* ── 一条刻意保留的边界(别当成漏做)────────────────────────────────
* **发配对码不是角色能力,是设备能力。** 设计意图是它需要 `device_token`,即
* "设备就在你手上"的在场证明;若允许 owner 远程给某台设备发码,这层证明就没了 ——
* 账号被盗就能自助扩权。故 `ISSUE_PAIRING_CODE` 不在本矩阵里,也不该被加进来。
*
* ⚠️ 但**当前实现并没有兑现这个意图**(2026-11-08 代码复核,勿再按注释想当然):
* `POST /api/pairing/code`(`server/business/index.js` 里那个路由的处理函数,
* 写作时在 :1205-1229)**没有任何认证** —— 它既不验设备凭据,也没有调用
* `requireUser()`(同文件里有这个函数,写作时在 :1582,别的端点都在用)。
* 只要 body 里给一个**存在的** `device_id` 就把码发出来;客户端
* `pairing_api.dart` 的取码方法也只发 `{ device_id }`。
* 推论:任何能访问该端点的人(**连登录都不需要**)可替任意已知 device_id 取码,
* 再自己拿去 `pairing/verify` 换成对该设备的查看授权 —— 码显示在谁的屏幕上一文不值。
* 已登记:`docs/decisions/OPEN-DECISIONS.md` 第 21 行 / PRD 矛盾 16。
* v2 的 M4′ 只修**认领码**(改为设备本地生成、随 agent:hello 上报),
* 配对码这处同款洞**本期不修**,需单独立项(会动 84 项安全回归网)。
*
* (行号只是"写作时"的定位辅助:这个文件正被并发修改,按行号找不如按
* 路由名/函数名找。校验这件事本身用一条命令就够:
* `grep -n "app.post('/api/pairing/code'" server/business/index.js`,
* 看它后面有没有 `requireUser`。)
*
* 为什么必须把注释改准、而不是留着原话:一条声称"这里有在场证明"的注释,
* 会让下一个读代码的人**停止审查这条链路**。注释性谎言比代码 bug 更难发现。
*
* ── 另一条:授权 ≠ 夺权 ──────────────────────────────────────────
* 被授予 `owner` 角色的成员**不能**转移归属、不能删除设备。
* 那两项只认 `devices.owner_id` 本人(见 `authorize()` 的 `ownerOnly`)。
* 否则"分享给我"就等于"这台设备归我了"。
*/
'use strict';
/** 可写入 `device_grants.role` 的取值集合(校验与 SQL CHECK 同源) */
const GRANT_ROLES = Object.freeze(['viewer', 'manager', 'owner']);
/** 角色强弱序(数值越大权限越宽)。`authorize` 只做这一种比较,不做集合差 */
const RANK = Object.freeze({ viewer: 1, manager: 2, owner: 3 });
/**
* 能力清单。`min` = 完成该动作所需的最低角色;`ownerOnly` = 只认设备归属本人。
*
* 命名口径:动词开头的 SCREAMING_SNAKE,便于在测试里穷举 × 角色。
*/
const CAP = Object.freeze({
VIEW_SNAPSHOT: { min: 'viewer' },
VIEW_LIVE: { min: 'viewer' },
VIEW_HISTORY: { min: 'viewer' }, // 深度另受档位保留期约束,不在此判定
EXPORT_DATA: { min: 'manager' },
INVITE_MEMBER: { min: 'manager' },
VIEW_MEMBERS: { min: 'manager' }, // 谁能看"还有谁有权限"本身就是一种信息
REVOKE_OWN_GRANT: { min: 'manager' }, // 解除"我自己"那条授权
RENAME_DEVICE: { min: 'manager' },
MANAGE_MEMBERS: { min: 'owner' }, // 改他人角色 / 移除他人
ROTATE_TOKEN: { min: 'owner' },
VIEW_AUDIT: { min: 'owner' },
TRANSFER_OWNER: { min: 'owner', ownerOnly: true },
DELETE_DEVICE: { min: 'owner', ownerOnly: true },
});
/**
* 档位配额(方案 6.4 表 8)。**数字只在这里**。
*
* 曾经这份数字在 `server/business/index.js` 出现两次(`PLAN_QUOTA` 常量与
* `/api/accounts/me` 里的内联字面量),改一处必漏另一处 —— 现统一到此处,
* 业务服务只引用不复制。
*
* `history_days` 是软限制(查询截断并如实标注),其余是硬限制(失败关闭)。
*/
const PLAN_TABLE = Object.freeze({
free: { devices: 3, history_days: 7, members_per_device: 1, export: false, audit: false },
pro: { devices: 10, history_days: 30, members_per_device: 3, export: true, audit: false },
// team.history_days = 30(v2 裁定,原 90)。三个理由都写在 SPEC-v2 §5.4:
// ① `HISTORY_WINDOWS` 最大键就是 30d,90 天**无窗口可兑现**;
// ② `init.sql` 的 TimescaleDB 保留策略是 30 天,物理上只有 30 天数据;
// ③ team 的差异化由 devices 30 / members_per_device 10 / audit 三项承担。
// 注意这条改动**不改变任何窗口判定结果**(`ms <= 90d` 与 `ms <= 30d` 筛出的集合相同),
// 它改的是 `/api/accounts/me` 的对外报数 —— 也就是"对外撒谎"的那一处。
team: { devices: 30, history_days: 30, members_per_device: 10, export: true, audit: true },
});
/**
* 档位**中文展示名** —— 唯一来源(API-CONTRACT-v2 §1 基线 4 / Q11)。
*
* 为什么和 `PLAN_TABLE` 同文件:中文名与额度是同一个"档位"概念的两个侧面,
* 分开放就会有人只改一处。客户端**不得**自建 id→中文 映射(那是第七次双份真相),
* 缺失一律显示 `未知档位`,**不得回落成机器键** `free`/`pro`/`team`。
*
* 用「版」不用「档」:上位概念已叫"档位"(tier),具体名写"免费版"可避免
* "档位:免费档"的重复;商业软件(基础版/专业版/企业版)也普遍用"版"。
*/
const PLAN_DISPLAY = Object.freeze({
free: Object.freeze({ name: '免费版' }),
pro: Object.freeze({ name: '专业版' }),
team: Object.freeze({ name: '团队版' }),
});
/** 档位缺失/非法一律回落到 free(不静默给高配额) */
function planOf(plan) {
return PLAN_TABLE[plan] || PLAN_TABLE.free;
}
/**
* 档位中文显示名。**未知档位返回 `null`**,不回落成 `PLAN_TABLE` 的 free 那一档。
*
* 为什么不复用 `planOf` 的回落:配额回落是"保守给最小额度",语义正确;
* 而把 `enterprise` 显示成「免费版」是**陈述了一个假事实**(用户明明不在免费档)。
* 客户端的兜底是「未知档位」—— 这句才是诚实的。
*/
function planNameOf(plan) {
const d = PLAN_DISPLAY[plan];
return d ? d.name : null;
}
/**
* 实际生效的档位额度 —— 允许**实例级覆盖免费档的设备上限**。
*
* 为什么只放开免费档:`free_device_limit` 是实例配置里的一项(向导会问、
* 管理端能改),运营者按自己的机器规格决定"白送几台"是合理的。而 pro/team
* 的数字是**产品定义**(付费买的就是那几个数),让运维改会让账单、文案、
* 权限矩阵三处对不上。
*
* 为什么放在这里而不是在业务层拼一张表:PLAN_TABLE 是额度的唯一真相源,
* 覆盖规则也应当在同一处,否则又会出现"两份数字,改一处漏一处"。
*/
function planFor(plan, { freeDeviceLimit } = {}) {
const key = PLAN_TABLE[plan] ? plan : 'free';
const base = PLAN_TABLE[key];
if (key === 'free' && Number.isInteger(freeDeviceLimit) && freeDeviceLimit >= 0) {
return { ...base, devices: freeDeviceLimit };
}
return base;
}
/* ══════════════════════════════════════════════════════════════════
* 档位字段接线台账 —— 防「装饰位」的唯一登记处
*
* ── 这个台账要防的是什么(读一遍再往 PLAN_TABLE 加字段)───────────────
* `PLAN_TABLE` 的每个字段都是一个**卖点承诺**。历史上 `export` 与 `audit`
* 两个字段活了整整一个版本周期都没接线:`export` 连端点都没有,
* `audit` 只按角色判(档位位白写)。它们没有报错、没有测试红、没有任何
* 信号 —— 因为**从来没有人做过"字段有没有消费者"这个差集**。
*
* 规则(矛盾 17 裁决,PRD §5.6.1):
* PLAN_TABLE 中每个字段必须**要么有消费者,要么在未接线清单里**。
*
* 落地方式:本台账为每个字段登记一个状态 + 证据。断言在
* `tests/permissions-test.js` 第 8 段(把键集合与本台账做**双向差集**,
* 差集非空即红),并逐条核验 `cites` 指向的字符串在源码里**真实存在** ——
* 否则台账会退化成第二份谎言(声称有消费者、其实早被重构掉了)。
*
* 加新字段的正确流程:先决定它接不接线。
* · 接线 → 写消费代码,然后在此登记 WIRED 并给 cites 证据;
* · 不接 → 登记 UNWIRED 并写明理由与裁定出处。
* 两条都不做 → 测试立刻红,绕不过去。
*
* ── 台账的第二个用途:决定对外投影 ────────────────────────────────
* `planProjectionKeys()` 用同一份台账决定 `/api/plans` 能返回哪些字段
* (只滤掉 UNWIRED)。**"有没有人读它"和"能不能对外说它"必须是同一个判断** ——
* 分成两处判断,就一定会出现"接口承诺了、代码没实现"这个本台账要防的形态。
*
* ── 台账的固有局限(别把它当万能)──────────────────────────────────
* 断言只能保证"键集合 ↔ 台账自洽 + cites 真实存在且在代码行上",
* **不能保证 `state` 语义正确**。反例:M3 落地后历史端点真开始读 `history_days`,
* 若台账忘了从 PARTIAL 升 WIRED,双向差集**不会红**(cites 仍然成立)。
* 所以 `pending` / `unwired` 的 `unlock` 与 `decidedRef` 是写给人看的**解除条件**,
* 靠人读、靠人执行升级动作;而"这条规则今天构造得出触发条件吗"要靠
* **fixture** 证明(PRD §9.8 触发构造审计 / 矛盾 19「永假规则」)。
* 两者互补:只有 unlock 没有 fixture → 忘了升级,PARTIAL 永远挂着;
* 只有 fixture 没有 unlock → 断言红了也不知道该等谁。
* ══════════════════════════════════════════════════════════════════ */
/** 字段状态词汇。顺序即"从真到假":wired > partial > pending > unwired */
const PLAN_WIRING_STATE = Object.freeze({
/** 有真实消费者,且 cites 能在源码里找到 */
WIRED: 'wired',
/** 有消费者但语义不完整(如只报数、不生效)—— 必须写明缺口 */
PARTIAL: 'partial',
/** 已裁定要接线但代码尚未落地 —— 必须写明 specRef 与解锁条件 */
PENDING: 'pending',
/** 无消费者,明示保留:不得用于任何对外投影(`planProjectionKeys()` 会滤掉它) */
UNWIRED: 'unwired',
});
/**
* 台账本体。键必须与 `PLAN_TABLE` 的字段**完全一致**(双向差集断言)。
*
* `cites` 里每条 `{ file, contains }` 都会被测试真的去读文件并 `includes` 校验。
* 故它不能写"大概在这附近",只能写**确实存在的那段字面量**。
*/
const PLAN_FIELD_WIRING = Object.freeze({
devices: Object.freeze({
state: PLAN_WIRING_STATE.WIRED,
cites: Object.freeze([
// 配额判定主体:未验证邮箱收紧到 1 台
{ file: 'server/business/index.js', contains: 'emailVerified ? table.devices' },
// /api/accounts/me 对外报数
{ file: 'server/business/index.js', contains: 'device_quota: table.devices' },
]),
note: '配额硬限制,失败关闭(402 E_QUOTA_EXCEEDED)',
}),
history_days: Object.freeze({
state: PLAN_WIRING_STATE.WIRED,
cites: Object.freeze([
// ① /api/accounts/me 对外报数(与 allowed_windows 同源同次计算)
{ file: 'server/business/index.js', contains: 'history_days: table.history_days' },
// ② 真正生效的那一处:历史端点按档位截断(M3)。
// 少了这条,"已生效"就只是句声称 —— 这正是本台账(以及它防的
// `export`/`audit` 两个装饰位)存在的理由。
{ file: 'server/business/index.js', contains: 'resolvePlanWindow(req.query.window' },
]),
note: '软限制:查询截断并如实标注(truncated/requested_window),不报错。'
+ 'M3 已落地;team 档 90→30 的收敛只改报数,不改变任何窗口判定(见 PLAN_TABLE 注释)',
}),
members_per_device: Object.freeze({
state: PLAN_WIRING_STATE.WIRED,
cites: Object.freeze([
// 真正拦截的那一行(不是报数行)—— 少了它,"已生效"就只是句声称
{ file: 'server/business/index.js', contains: "const cap = planOf(ownerRow ? ownerRow.plan : 'free').members_per_device" },
{ file: 'server/business/index.js', contains: 'E_MEMBER_QUOTA' },
// /api/accounts/me 对外报数
{ file: 'server/business/index.js', contains: 'members_per_device: table.members_per_device' },
]),
note: '邀请超限返回 402 E_MEMBER_QUOTA',
}),
audit: Object.freeze({
state: PLAN_WIRING_STATE.WIRED,
cites: Object.freeze([
// 档位判定必须挂在角色判定**之后**(契约 §14.2 第 10 条):
// 顺序反了,非 owner 的 free 用户会先收到"升级就能看",升级完仍然 403。
{ file: 'server/business/index.js', contains: 'planForAccount(plan).audit === false' },
{ file: 'server/business/index.js', contains: "error: 'E_PLAN_REQUIRED'" },
]),
note: '免费档读设备审计 → 403 E_PLAN_REQUIRED(带 upgrade_url:原因可变可购买,'
+ '按契约 §3 纪律 2 必须给升级出口)。**只有 team 的 audit 是 true**,'
+ '故 pro 同样 403 —— 判定双向验:free/pro 拦、team 放行,只验拦截不算过',
}),
export: Object.freeze({
state: PLAN_WIRING_STATE.UNWIRED,
cites: Object.freeze([]),
decidedRef: 'PRD 矛盾 17 裁决(不删字段,登记未接线)/ §5.6.1'
+ ' + 2026-11-08 追加裁定:**不进入 `/api/plans` 投影**',
note: '零消费者,且 EXPORT_DATA 能力无任何路由。真相源保留(不让设计意图从 '
+ 'PLAN_TABLE 里消失),但**不得进入任何对外投影**:`/api/plans` 必须滤掉 '
+ 'state = unwired 的字段(见 `planProjectionKeys()`)。'
+ '为什么必须滤、而不是"返回但打标":`x-v2-status` 是 OpenAPI 扩展,'
+ '**运行时 JSON 里根本不存在** —— 接口一旦返回 `export: true`,禁止令当场消失,'
+ '读响应体的人不会去看文档,写客户端的人看到 true 就会做导出按钮。'
+ '文档能约束读文档的人,约束不了读响应体的人。',
}),
});
/** `PLAN_TABLE` 的字段名清单(供双向差集断言使用,避免各测试各写一份) */
const PLAN_TABLE_KEYS = Object.freeze(Object.keys(PLAN_TABLE.free));
/**
* 结构自检:把 `PLAN_TABLE` 的键集合与台账做**双向差集**。
*
* 纯函数、不碰文件系统(`cites` 的存在性核验见 `verifyPlanFieldCites`)。
*
* `planTable` / `ledger` 可注入 —— 不是为了线上灵活,而是为了让测试能做
* **负向对照**:故意递一份缺字段的台账,断言它真的红。一个从没见过红的护栏
* 与没有护栏无法区分(本仓库为"假装在工作"的护栏单独立过一轮修复)。
*
* @param {{planTable?:object, ledger?:object}} [io]
* @returns {{ok:boolean, missing:string[], stale:string[], badMeta:string[],
* byState:Record<string,string[]>, plansDisagree:boolean}}
* · missing —— PLAN_TABLE 有、台账没有(新增字段忘了登记)
* · stale —— 台账有、PLAN_TABLE 没有(删了字段忘了销账)
* · badMeta —— 状态非法 / wired·partial 缺 cites / pending 缺 specRef / unwired 缺理由
* · plansDisagree —— 三档的字段集合不一致(某一档多了别人没有的字段)
*/
function checkPlanFieldCoverage({ planTable = PLAN_TABLE, ledger = PLAN_FIELD_WIRING } = {}) {
const keys = Object.keys(planTable.free || {});
const ledgerKeys = Object.keys(ledger);
const missing = keys.filter((k) => !ledgerKeys.includes(k));
const stale = ledgerKeys.filter((k) => !keys.includes(k));
// 三档必须字段一致:否则"某一档有某卖点、另一档没有"这种差异根本不该存在于
// 一张布尔表里 —— 它应该是一个独立字段。
const refKeys = keys.join(',');
const plansDisagree = Object.keys(planTable).some(
(p) => Object.keys(planTable[p]).join(',') !== refKeys
);
const badMeta = [];
const byState = { wired: [], partial: [], pending: [], unwired: [] };
const validStates = Object.values(PLAN_WIRING_STATE);
for (const [k, v] of Object.entries(ledger)) {
if (!v || !validStates.includes(v.state)) {
badMeta.push(`${k}: 状态非法 "${v && v.state}"`);
continue;
}
byState[v.state].push(k);
if ((v.state === PLAN_WIRING_STATE.WIRED || v.state === PLAN_WIRING_STATE.PARTIAL)
&& (!Array.isArray(v.cites) || v.cites.length === 0)) {
badMeta.push(`${k}: ${v.state} 但没给 cites 证据`);
}
if (v.state === PLAN_WIRING_STATE.PENDING && !v.specRef) {
badMeta.push(`${k}: pending 但没给 specRef(谁裁定要接、在哪一节)`);
}
if (v.state === PLAN_WIRING_STATE.UNWIRED && !(v.note || v.decidedRef)) {
badMeta.push(`${k}: unwired 但没写理由`);
}
for (const c of v.cites || []) {
if (!c || typeof c.file !== 'string' || typeof c.contains !== 'string' || !c.contains) {
badMeta.push(`${k}: cites 条目必须是 { file, contains } 且 contains 非空`);
}
}
}
return {
ok: missing.length === 0 && stale.length === 0 && badMeta.length === 0 && !plansDisagree,
missing, stale, badMeta, byState, plansDisagree,
};
}
/**
* 对外投影:`/api/plans` 允许返回的字段集合。
*
* 规则一句话:**真相源保留,对外投影过滤** —— `PLAN_TABLE` 的键集合里只会去掉
* `state === unwired` 的那些(今天只有一个:`export`)。
*
* 为什么必须是"过滤"而不是"选几个":写成白名单(`['devices','history_days',...]`)
* 就成了第二份会漂移的字段清单;而按台账状态过滤,新增字段时**默认被投影**
* (因为默认要接线),只有显式登记 UNWIRED 才会被滤掉 —— 这个默认方向是对的:
* 少投影一个该投的字段会被前端当场发现,多投影一个没实现的字段则会被沉默接受。
*
* 这是台账的第二个用途(第一个是防漏登,见 `checkPlanFieldCoverage`):
* **同一份台账同时决定"要不要有人读它"和"能不能对外说它"**。
*
* @param {{planTable?:object, ledger?:object}} [io]
* @returns {string[]} 键名数组,顺序与 `PLAN_TABLE` 字段声明顺序一致
*/
function planProjectionKeys({ planTable = PLAN_TABLE, ledger = PLAN_FIELD_WIRING } = {}) {
return Object.keys(planTable.free || {}).filter((k) => {
const entry = ledger[k];
// 台账里查不到 → 不投影(未知状态不得外泄),且这属于缺账,
// checkPlanFieldCoverage 的 `missing` 会先把这件事报出来。
if (!entry) return false;
return entry.state !== PLAN_WIRING_STATE.UNWIRED;
});
}
/**
* 核验台账里的每条 `cites` 证据**在源码里真实存在,且真的在代码里**。
*
* 为什么要做这件事:台账最容易的退化方式不是被删掉,而是**留着但已失真** ——
* 消费代码被重构掉了、行号变了、字段改名了,而台账还宣称"有消费者"。
* 那样它就变成了第二份谎言,比没有台账更糟。
*
* 为什么要额外判「命中的那一行不是注释」:`text.includes()` 分不清
* `const x = table.devices` 与 `// 这里会读 table.devices`。后者是**最省力**
* 的作假方式 —— 想给一个死字段补"证据",写行注释比写消费代码快一个数量级。
* 本仓库已经吃过一次"注释性谎言"的亏(见文件头配对码那段),不再赌第二次。
*
* 用注入 `readFile` 而不是直接 require('fs'):保持本模块可在任意环境单测
* (与文件系统解耦),并用 `root` 注入仓库根便于测试。
*
* @param {{readFile:(absPath:string)=>string, root:string}} io
* @returns {{ok:boolean, checked:number, missing:string[], commentOnly:string[]}}
* · missing —— 文件读不到,或字符串已不在文件里(证据失真)
* · commentOnly —— 字符串在,但所在行是注释(等于没接线)
*/
function verifyPlanFieldCites({ readFile, root }) {
const missing = [];
const commentOnly = [];
let checked = 0;
for (const [k, v] of Object.entries(PLAN_FIELD_WIRING)) {
for (const c of v.cites || []) {
checked++;
const abs = `${root}/${c.file}`;
let text;
try {
text = readFile(abs);
} catch (e) {
missing.push(`${k}: 读不到 ${c.file}(${e.message})`);
continue;
}
const at = text.indexOf(c.contains);
if (at < 0) {
missing.push(`${k}: ${c.file} 里已找不到 ${JSON.stringify(c.contains)} —— 证据失真`);
continue;
}
// 取命中处所在行,去掉行首空白后判断是不是注释形态
const lineStart = text.lastIndexOf('\n', at) + 1;
const lineEnd = text.indexOf('\n', at);
const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd).trim();
if (/^(\/\/|\/\*|\*|\*\/)/.test(line)) {
commentOnly.push(`${k}: ${c.file} 里 ${JSON.stringify(c.contains)} 只出现在注释里`);
}
}
}
return { ok: missing.length === 0 && commentOnly.length === 0, checked, missing, commentOnly };
}
/** 角色是否合法(写库前必过) */
function isGrantRole(role) {
return typeof role === 'string' && GRANT_ROLES.includes(role);
}
/** 纯比较:某角色是否够做某能力。能力不存在 = 不具备(不 throw,便于判定失败关闭) */
function can(role, capability) {
const rule = CAP[capability];
if (!rule) return false;
const have = RANK[role];
if (!have) return false;
return have >= RANK[rule.min];
}
/**
* 有效角色推导 —— 优先级写死,避免"归授权行盖过归属"这种歧义。
*
* 1. `devices.owner_id == 我` → owner(归属压过一切授权行)
* 2. `device_grants(device_id, 我)` → 该行的 role,且未过期
* 3. 都没有 → null
*
* @param {{identity:object, ownerId?:string|number, grant?:object}} p
* @returns {{role:('viewer'|'manager'|'owner'|null), via:('owner'|'grant'|null)}}
*/
function roleFor({ identity, ownerId, grant }) {
if (!identity || !identity.ok) return { role: null, via: null };
if (identity.kind === 'user') {
if (ownerId != null && String(ownerId) === identity.userId) {
return { role: 'owner', via: 'owner' };
}
}
// 授权行:LEGACY 身份也能命中(那是兼容通道的正当用法)。
// 但**归属权只给 USER** —— 自报字符串证明不了任何归属。
if (grant) {
if (grant.expires_at && Number(grant.expires_at) <= Date.now()) {
return { role: null, via: null };
}
const role = grant.role || 'viewer';
if (!isGrantRole(role)) return { role: null, via: null };
return { role, via: 'grant' };
}
return { role: null, via: null };
}
/**
* 唯一的授权判定入口。
*
* 失败关闭的三处:
* · 身份不合法 → 拒
* · 能力名不存在 → 拒(打错字必须拒绝,不能"查不到就放行")
* · 推不出角色 → 拒
*
* @returns {{ok:boolean, role:string|null, reason:string}}
* reason 取值固定,便于测试穷举:no_identity / bad_capability / no_access /
* insufficient_role / owner_only
*/
function authorize({ identity, ownerId, grant, capability }) {
if (!identity || !identity.ok) return { ok: false, role: null, reason: 'no_identity' };
const rule = CAP[capability];
if (!rule) return { ok: false, role: null, reason: 'bad_capability' };
const { role, via } = roleFor({ identity, ownerId, grant });
if (!role) return { ok: false, role: null, reason: 'no_access' };
if (rule.ownerOnly && via !== 'owner') {
return { ok: false, role, reason: 'owner_only' };
}
if (!can(role, capability)) {
return { ok: false, role, reason: 'insufficient_role' };
}
return { ok: true, role, reason: 'ok' };
}
/**
* 列表用("我能看的设备"):等价于 VIEW_SNAPSHOT。
* 保留此函数是为了让调用方读起来是意图而不是权限名。
*/
function canView(identity, ownerId, grant) {
return authorize({ identity, ownerId, grant, capability: 'VIEW_SNAPSHOT' }).ok;
}
/** 能力名清单(测试与客户端登记用,顺序稳定) */
const CAPABILITY_NAMES = Object.freeze(Object.keys(CAP));
module.exports = {
GRANT_ROLES,
RANK,
CAP,
CAPABILITY_NAMES,
PLAN_TABLE,
PLAN_DISPLAY,
planOf,
planNameOf,
planFor,
isGrantRole,
can,
roleFor,
authorize,
canView,
// 档位字段接线台账(防装饰位)。断言在 tests/permissions-test.js 第 8 段。
PLAN_TABLE_KEYS,
PLAN_WIRING_STATE,
PLAN_FIELD_WIRING,
checkPlanFieldCoverage,
planProjectionKeys,
verifyPlanFieldCites,
};