Archived
路由表字面重复也掀桌子;死信没收件人时出声;票据横向范围与「权限只到端」写进 ADR
**I1:字面路由重复注册仍是静默覆盖 —— 与参数侧的掀桌子不对称。**
两条**字面**契约同方法同路径撞上时,后写覆盖先写,命中谁全看 `Object.values(contracts)`
的顺序。参数模版撞了会抛(理由是「命中谁只看声明顺序 = 静默判错权限」),而字面这一半
没设防:同一个病、同一个后果。把守卫从 59 处收成 1 处之后,注册表是**唯一**的授权
事实源,它的自检不该有半边缺口。`size` 一并从 `routes.length` 改成
`literal.size + patterns.length`(重复被拒之后两者相等,但语义上它说的是「表里有几条」)。
`lookup` 上补一句:它只认 HTTP_METHODS 那五个,**归一是调用方的职责**;测里钉了
`lookup('HEAD', …)` 仍是 undefined —— 将来有人把中间件那行归一挪走,这条分工还在。
**顺手两条:**
- 死信通知的收件人为空时不出声(`deadLetters.report`),而 container 的 governanceStaff
对同一件事会往 stderr 喊一句 —— 后者的理由(把「出事了」无声丢掉最糟)在前者同样成立。
- ADR-010 状态行补「已被 ADR-012 取代」:ADR-012 说了取代,但先翻到 ADR-010 的人看不到。
**两条不修,写进文档:**
- 票据的**横向范围**进 ADR-012 与 debt:票据里没有用途,60 秒内它等于持票人的
**全部读权限** —— 凭一枚为下载附件取的票据,同一分钟里读得了整张客户主数据。
不是提权(那些读权限他本来就有),风险在留痕面(URL 走浏览器历史 / 反代日志 / Referer)。
收敛做法记进 debt:签发时把用途绑进票据(scope ∈ {stream, file}),或中间件对认票据的
路径设白名单。
- 「权限只到端」的影响面进 ADR-012 的「后果」:design 现在同时意味着装活动、跑测试台、
传文件、探大模型;design|ops 意味着 `GET /api/gov/data/:domain/:object` 能翻整张客户
主数据(**从前要 data:read 才行**)。半年后有人问「为什么设计的人看得到客户手机号」,
ADR 里得有这句话在。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
This commit is contained in:
@@ -36,6 +36,13 @@ describe('路由注册表', () => {
|
||||
it('未知路由 → undefined', () => {
|
||||
expect(registry.lookup('GET', '/api/nope')).toBeUndefined();
|
||||
});
|
||||
it('只认五个 HTTP 方法:HEAD 查不到 —— 归一是中间件的职责,不是表的', () => {
|
||||
// 框架把 HEAD 改写成 GET 再分发,而请求行上的方法仍是 HEAD;
|
||||
// 表只按 HTTP_METHODS 装,所以这里如实回 undefined。
|
||||
// 这条钉住的是分工:哪天有人把中间件里那行归一挪走,鉴权就漏了,而这里仍然该是 undefined。
|
||||
expect(registry.lookup('HEAD', '/api/gov/tasks')).toBeUndefined();
|
||||
expect(registry.lookup('GET', '/api/gov/tasks')).toBe('ops');
|
||||
});
|
||||
it('数组归属原样回', () => {
|
||||
expect(registry.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']);
|
||||
});
|
||||
@@ -124,6 +131,22 @@ describe('建表自检 —— 两条模版能撞就不许建', () => {
|
||||
]),
|
||||
).not.toThrow();
|
||||
});
|
||||
it('同一条字面路由注册两遍 → 抛(后写的会静默盖掉先写的)', () => {
|
||||
expect(
|
||||
reg([
|
||||
{ method: 'GET', path: '/api/gov/staff', access: 'ops' },
|
||||
{ method: 'GET', path: '/api/gov/staff', access: 'design' },
|
||||
]),
|
||||
).toThrow(/注册了两遍/);
|
||||
});
|
||||
it('同路径不同方法照旧不算撞', () => {
|
||||
expect(
|
||||
reg([
|
||||
{ method: 'GET', path: '/api/gov/staff', access: 'ops' },
|
||||
{ method: 'POST', path: '/api/gov/staff', access: 'ops' },
|
||||
]),
|
||||
).not.toThrow();
|
||||
});
|
||||
it('真实 95 条契约路由建得起来(没有暗撞)', () => {
|
||||
expect(() => buildRouteRegistry(routesOf(Object.values(contracts)))).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,11 @@ export type RegisteredRoute = {
|
||||
|
||||
export type RouteRegistry = {
|
||||
readonly size: number;
|
||||
/** 按请求的方法与实际路径查归属;查不到回 undefined(中间件按 any 处置,随后 404) */
|
||||
/**
|
||||
* 按请求的方法与实际路径查归属;查不到回 undefined(中间件按 any 处置,随后 404)。
|
||||
* **只认 `HTTP_METHODS` 那五个方法,归一是调用方的职责** —— 比如 HEAD:框架把它
|
||||
* 改写成 GET 再分发,这里照字面查必然 miss,中间件必须先归一再来查。
|
||||
*/
|
||||
lookup(method: string, path: string): RouteAccess | undefined;
|
||||
/** 按注册时的路径模版查(覆盖测试用):`has('GET', '/api/gov/tasks/:taskId/log')` */
|
||||
has(method: string, pathPattern: string): boolean;
|
||||
@@ -72,8 +76,22 @@ export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteReg
|
||||
const literal = new Map<string, RouteAccess>();
|
||||
const patterns: Array<{ method: string; pattern: string; re: RegExp; access: RouteAccess }> = [];
|
||||
for (const r of routes) {
|
||||
if (r.path.includes(':')) patterns.push({ method: r.method, pattern: r.path, re: matcherOf(r.path), access: r.access });
|
||||
else literal.set(`${r.method} ${r.path}`, r.access);
|
||||
if (r.path.includes(':')) {
|
||||
patterns.push({ method: r.method, pattern: r.path, re: matcherOf(r.path), access: r.access });
|
||||
continue;
|
||||
}
|
||||
/*
|
||||
* 同方法同路径注册两遍:后写的静默盖掉先写的,命中谁全看 `Object.values(contracts)`
|
||||
* 的顺序 —— 与两条模版相撞是同一个病、同一个后果(静默判错权限)。
|
||||
* 注册表是唯一的授权事实源,它的自检不该有半边缺口:字面这一半照样掀桌子。
|
||||
*/
|
||||
const key = `${r.method} ${r.path}`;
|
||||
if (literal.has(key)) {
|
||||
throw new Error(
|
||||
`路由归属表冲突:${key} 注册了两遍 —— 后写的会静默盖掉先写的,等于静默判错权限。删掉重复的那一条。`,
|
||||
);
|
||||
}
|
||||
literal.set(key, r.access);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -96,7 +114,8 @@ export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteReg
|
||||
}
|
||||
|
||||
return {
|
||||
size: routes.length,
|
||||
// 实际可查询的条数(重复被拒之后它与 routes.length 相等 —— 但语义上它说的是表里有几条)
|
||||
size: literal.size + patterns.length,
|
||||
lookup(method, path) {
|
||||
const exact = literal.get(`${method} ${path}`);
|
||||
if (exact !== undefined) return exact;
|
||||
|
||||
@@ -2620,7 +2620,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
report: async (input) => {
|
||||
writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`);
|
||||
// 收件人 = 能进运营中心的人(触发器的启停归运营;ADR-012 权限只到端)
|
||||
for (const person of await deps.org.staffWithEnd('ops')) {
|
||||
const recipients = await deps.org.staffWithEnd('ops');
|
||||
// 一个收件人都没有:这条通知这一次谁也收不到 —— 把「出事了」无声丢掉最糟,
|
||||
// 所以出声(与 container.ts 的 governanceStaff 同一口径)
|
||||
if (recipients.length === 0) {
|
||||
writeLog(
|
||||
'[trigger] 死信通知这一次没有收件人 —— 没有能进运营中心的活跃员工。' +
|
||||
'去运营中心的「角色 → 端」把某个 realm 角色配上 ops,或让持该角色的人先登录一次。',
|
||||
);
|
||||
}
|
||||
for (const person of recipients) {
|
||||
await deps.message.notify({
|
||||
staffId: person.id,
|
||||
title: `触发器起单失败:${input.triggerId}`,
|
||||
|
||||
Reference in New Issue
Block a user