im_wower
·
2026-03-22
control-api.ts
1import type { AuthAction } from "./actions.js";
2import type { AuthPrincipal, AuthResourceOwnership } from "./model.js";
3import { authorize, type AuthDecision } from "./policy.js";
4
5export type ControlApiMethod = "GET" | "POST";
6
7export interface ControlApiAuthRule {
8 method: ControlApiMethod;
9 pathPattern: string;
10 action: AuthAction;
11 summary: string;
12}
13
14export interface ControlApiAuthorizationInput {
15 method: ControlApiMethod;
16 path: string;
17 principal: AuthPrincipal;
18 resource?: AuthResourceOwnership;
19}
20
21export interface ControlApiAuthorizationResult extends AuthDecision {
22 matchedRule?: ControlApiAuthRule;
23}
24
25export const CONTROL_API_AUTH_RULES = [
26 {
27 method: "POST",
28 pathPattern: "/v1/controllers/heartbeat",
29 action: "controllers.heartbeat",
30 summary: "controller 心跳"
31 },
32 {
33 method: "POST",
34 pathPattern: "/v1/leader/acquire",
35 action: "leader.acquire",
36 summary: "获取或续租 leader lease"
37 },
38 {
39 method: "GET",
40 pathPattern: "/v1/controllers",
41 action: "controllers.list",
42 summary: "列出 controller 摘要"
43 },
44 {
45 method: "POST",
46 pathPattern: "/v1/tasks",
47 action: "tasks.create",
48 summary: "创建 task"
49 },
50 {
51 method: "GET",
52 pathPattern: "/v1/tasks",
53 action: "tasks.list",
54 summary: "列出 task 摘要"
55 },
56 {
57 method: "POST",
58 pathPattern: "/v1/tasks/claim",
59 action: "tasks.claim",
60 summary: "领取待规划 task 或 runnable step"
61 },
62 {
63 method: "POST",
64 pathPattern: "/v1/tasks/:task_id/plan",
65 action: "tasks.plan",
66 summary: "持久化已验收 plan"
67 },
68 {
69 method: "POST",
70 pathPattern: "/v1/steps/:step_id/heartbeat",
71 action: "steps.heartbeat",
72 summary: "step 心跳"
73 },
74 {
75 method: "POST",
76 pathPattern: "/v1/steps/:step_id/checkpoint",
77 action: "steps.checkpoint",
78 summary: "写 step checkpoint"
79 },
80 {
81 method: "POST",
82 pathPattern: "/v1/steps/:step_id/complete",
83 action: "steps.complete",
84 summary: "标记 step 完成"
85 },
86 {
87 method: "POST",
88 pathPattern: "/v1/steps/:step_id/fail",
89 action: "steps.fail",
90 summary: "标记 step 失败"
91 },
92 {
93 method: "POST",
94 pathPattern: "/v1/system/pause",
95 action: "system.pause",
96 summary: "暂停自动化"
97 },
98 {
99 method: "POST",
100 pathPattern: "/v1/system/resume",
101 action: "system.resume",
102 summary: "恢复自动化"
103 },
104 {
105 method: "POST",
106 pathPattern: "/v1/system/drain",
107 action: "system.drain",
108 summary: "drain 自动化"
109 },
110 {
111 method: "POST",
112 pathPattern: "/v1/system/promote",
113 action: "maintenance.promote",
114 summary: "promote 维护操作"
115 },
116 {
117 method: "POST",
118 pathPattern: "/v1/system/demote",
119 action: "maintenance.demote",
120 summary: "demote 维护操作"
121 },
122 {
123 method: "GET",
124 pathPattern: "/v1/system/state",
125 action: "system.state.read",
126 summary: "读取系统状态"
127 },
128 {
129 method: "GET",
130 pathPattern: "/v1/runs",
131 action: "runs.list",
132 summary: "列出 run 摘要"
133 },
134 {
135 method: "GET",
136 pathPattern: "/v1/tasks/:task_id/logs",
137 action: "tasks.logs.read",
138 summary: "读取 task 日志"
139 },
140 {
141 method: "GET",
142 pathPattern: "/v1/tasks/:task_id",
143 action: "tasks.read",
144 summary: "读取 task 详情"
145 },
146 {
147 method: "GET",
148 pathPattern: "/v1/runs/:run_id",
149 action: "runs.read",
150 summary: "读取 run 详情"
151 }
152] satisfies ReadonlyArray<ControlApiAuthRule>;
153
154export function findControlApiAuthRule(
155 method: ControlApiMethod,
156 path: string
157): ControlApiAuthRule | null {
158 for (const rule of CONTROL_API_AUTH_RULES) {
159 if (rule.method === method && matchesRoutePattern(rule.pathPattern, path)) {
160 return rule;
161 }
162 }
163
164 return null;
165}
166
167export function authorizeControlApiRoute(
168 input: ControlApiAuthorizationInput
169): ControlApiAuthorizationResult {
170 const matchedRule = findControlApiAuthRule(input.method, input.path);
171
172 if (!matchedRule) {
173 return {
174 ok: false,
175 reason: "route_not_modeled",
176 statusCode: 404
177 };
178 }
179
180 const decision = authorize({
181 principal: input.principal,
182 action: matchedRule.action,
183 resource: input.resource
184 });
185
186 return {
187 ...decision,
188 matchedRule
189 };
190}
191
192export function describeControlApiAuthSurface(): string[] {
193 return CONTROL_API_AUTH_RULES.map(
194 (rule) => `${rule.method} ${rule.pathPattern} -> ${rule.action} (${rule.summary})`
195 );
196}
197
198function matchesRoutePattern(pattern: string, actualPath: string): boolean {
199 const patternSegments = normalizePath(pattern);
200 const actualSegments = normalizePath(actualPath);
201
202 if (patternSegments.length !== actualSegments.length) {
203 return false;
204 }
205
206 return patternSegments.every((segment, index) => {
207 if (segment.startsWith(":")) {
208 return actualSegments[index] !== undefined;
209 }
210
211 return segment === actualSegments[index];
212 });
213}
214
215function normalizePath(path: string): string[] {
216 const normalizedPath = path.replace(/\/+$/u, "");
217 return normalizedPath.split("/").filter((segment) => segment.length > 0);
218}