## 三件事
**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。
519 lines
26 KiB
JavaScript
519 lines
26 KiB
JavaScript
/**
|
||
* 角色与能力矩阵 —— 唯一真相源(用户机制第一层)
|
||
*
|
||
* ── 为什么单独一个文件 ────────────────────────────────────────────
|
||
* "谁能做什么"会被三处使用:业务服务端点、网关订阅判定、测试。
|
||
* 本仓库已因"同一份清单写两处"翻车六次(指标字段 / 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,
|
||
};
|