Archived
**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
129 lines
5.2 KiB
TypeScript
129 lines
5.2 KiB
TypeScript
/**
|
|
* 路由注册表(ADR-012)—— 「哪条路由归哪个端」的唯一事实源是契约本身:
|
|
* 每条 `RouteContract` 自带 `access`,这里把契约命名空间扫一遍,给中间件一个 `lookup`。
|
|
* 新路由写在契约里就自动生效,不必谁记得再加一行守卫(漏一条就是一个没人管的口子)。
|
|
*
|
|
* 匹配:`:name` 段认任意非空段;字面精确命中优先于参数段命中;方法不同不混。
|
|
*/
|
|
import type { HttpMethod, RouteAccess, RouteContract } from '@contracts';
|
|
import { HTTP_METHODS } from '@contracts';
|
|
|
|
export type RegisteredRoute = {
|
|
readonly method: HttpMethod;
|
|
readonly path: string;
|
|
readonly access: RouteAccess;
|
|
};
|
|
|
|
export type RouteRegistry = {
|
|
readonly size: number;
|
|
/**
|
|
* 按请求的方法与实际路径查归属;查不到回 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;
|
|
};
|
|
|
|
const METHODS: readonly string[] = HTTP_METHODS;
|
|
|
|
export function isRouteContract(value: unknown): value is RouteContract {
|
|
if (typeof value !== 'object' || value === null) return false;
|
|
const v = value as { method?: unknown; path?: unknown; access?: unknown; output?: unknown };
|
|
return (
|
|
typeof v.method === 'string' &&
|
|
METHODS.includes(v.method) &&
|
|
typeof v.path === 'string' &&
|
|
v.access !== undefined &&
|
|
v.output !== undefined
|
|
);
|
|
}
|
|
|
|
/** 从一堆导出里挑出路由契约(`Object.values(contracts)` 喂进来) */
|
|
export function routesOf(values: readonly unknown[]): RegisteredRoute[] {
|
|
return values.filter(isRouteContract).map((r) => ({ method: r.method, path: r.path, access: r.access }));
|
|
}
|
|
|
|
function escapeSegment(segment: string): string {
|
|
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function matcherOf(pattern: string): RegExp {
|
|
const body = pattern
|
|
.split('/')
|
|
.map((seg) => (seg.startsWith(':') ? '[^/]+' : escapeSegment(seg)))
|
|
.join('/');
|
|
return new RegExp(`^${body}$`);
|
|
}
|
|
|
|
/**
|
|
* 两条模版会不会匹配到同一个具体路径:同段数,且每一位都兼容
|
|
* (参数段与任何段兼容;字面段只与相同的字面兼容)。
|
|
*/
|
|
function canCollide(a: string, b: string): boolean {
|
|
const sa = a.split('/');
|
|
const sb = b.split('/');
|
|
if (sa.length !== sb.length) return false;
|
|
return sa.every((seg, i) => {
|
|
const other = sb[i] ?? '';
|
|
if (seg.startsWith(':') || other.startsWith(':')) return true;
|
|
return seg === other;
|
|
});
|
|
}
|
|
|
|
export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteRegistry {
|
|
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 });
|
|
continue;
|
|
}
|
|
/*
|
|
* 同方法同路径注册两遍:后写的静默盖掉先写的,命中谁全看 `Object.values(contracts)`
|
|
* 的顺序 —— 与两条模版相撞是同一个病、同一个后果(静默判错权限)。
|
|
* 注册表是唯一的授权事实源,它的自检不该有半边缺口:字面这一半照样掀桌子。
|
|
*/
|
|
const key = `${r.method} ${r.path}`;
|
|
if (literal.has(key)) {
|
|
throw new Error(
|
|
`路由归属表冲突:${key} 注册了两遍 —— 后写的会静默盖掉先写的,等于静默判错权限。删掉重复的那一条。`,
|
|
);
|
|
}
|
|
literal.set(key, r.access);
|
|
}
|
|
|
|
/*
|
|
* 两条模版能匹配到同一个具体路径 = 查它时命中谁全看声明顺序 —— 那是**静默判错权限**,
|
|
* 比启动失败严重得多。契约是我们自己写的,撞了就是写错了:开发期就掀桌子。
|
|
* 只检模版之间;字面与参数并存是**有意的**优先级(字面赢),不算冲突。
|
|
*/
|
|
for (let i = 0; i < patterns.length; i += 1) {
|
|
for (let j = i + 1; j < patterns.length; j += 1) {
|
|
const a = patterns[i];
|
|
const b = patterns[j];
|
|
if (a === undefined || b === undefined) continue;
|
|
if (a.method === b.method && canCollide(a.pattern, b.pattern)) {
|
|
throw new Error(
|
|
`路由归属表冲突:${a.method} ${a.pattern} 与 ${a.method} ${b.pattern} 会匹配到同一个具体路径 —— ` +
|
|
'命中谁只看声明顺序,等于静默判错权限。改掉其中一条的路径。',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
// 实际可查询的条数(重复被拒之后它与 routes.length 相等 —— 但语义上它说的是表里有几条)
|
|
size: literal.size + patterns.length,
|
|
lookup(method, path) {
|
|
const exact = literal.get(`${method} ${path}`);
|
|
if (exact !== undefined) return exact;
|
|
return patterns.find((p) => p.method === method && p.re.test(path))?.access;
|
|
},
|
|
has(method, pathPattern) {
|
|
return literal.has(`${method} ${pathPattern}`) || patterns.some((p) => p.method === method && p.pattern === pathPattern);
|
|
},
|
|
};
|
|
}
|