codetrap 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
1
+ export const WEB_TEXT = {
2
+ en: {
3
+ "app.subtitle": "review console",
4
+ "nav.review": "Review",
5
+ "nav.library": "Library",
6
+ "nav.insights": "Insights",
7
+ "action.refresh": "Refresh",
8
+ "action.add": "Add",
9
+ "action.deleteSession": "Delete",
10
+ "action.cleanDeletedCandidates": "Clear deleted candidates",
11
+ "section.sessions": "sessions",
12
+ "placeholder.projectPath": "/path/to/project",
13
+ "title.candidateInbox": "candidate inbox",
14
+ "title.candidateDetail": "candidate detail",
15
+ "title.trapLibrary": "trap library",
16
+ "title.trapDetail": "trap detail",
17
+ "title.growthInsights": "growth insights",
18
+ "title.insightDetail": "insight detail",
19
+ "title.recentTraps": "recent traps",
20
+ "title.mostViewed": "most viewed",
21
+ "title.recentHighSeverity": "recent high severity",
22
+ "title.evidence": "evidence",
23
+ "title.possibleConflicts": "possible conflicts",
24
+ "title.before": "Before",
25
+ "title.after": "After",
26
+ "tab.inbox": "Inbox {count}",
27
+ "tab.reviewed": "Reviewed {count}",
28
+ "meta.noProject": "no project selected",
29
+ "meta.noSession": "no session selected",
30
+ "meta.sessionCounts": "{goal} / {pending} pending, {reviewed} reviewed",
31
+ "meta.libraryCounts": "{shown} shown / {loaded} loaded / {sort}",
32
+ "meta.insightCounts": "{count} traps / {status} status",
33
+ "meta.selectCandidate": "select a candidate",
34
+ "meta.selectTrap": "select a trap",
35
+ "meta.selectProject": "select a project",
36
+ "empty.noProjects": "No projects",
37
+ "empty.noSessions": "No sessions",
38
+ "empty.noPending": "No pending candidates",
39
+ "empty.noReviewed": "No reviewed candidates",
40
+ "empty.noTrapMatches": "No traps match this view",
41
+ "empty.noTrapSelected": "No trap selected",
42
+ "empty.loadingTrapDetails": "Loading trap details",
43
+ "empty.noCandidateSelected": "No candidate selected",
44
+ "empty.noEvidence": "No evidence",
45
+ "empty.noData": "No data",
46
+ "empty.noTraps": "No traps",
47
+ "action.viewTrap": "View trap",
48
+ "action.clearFilters": "Clear filters",
49
+ "action.save": "Save",
50
+ "action.accept": "Accept",
51
+ "action.reject": "Reject",
52
+ "action.acceptAnyway": "Accept anyway",
53
+ "action.supersede": "Supersede",
54
+ "placeholder.searchTraps": "Search title, context, mistake, fix, tags",
55
+ "placeholder.anyModule": "any module",
56
+ "placeholder.anyOwner": "any owner",
57
+ "placeholder.supersedesId": "supersedes id",
58
+ "label.scope": "Scope",
59
+ "label.status": "Status",
60
+ "label.category": "Category",
61
+ "label.sort": "Sort",
62
+ "label.module": "Module",
63
+ "label.owner": "Owner",
64
+ "label.title": "Title",
65
+ "label.severity": "Severity",
66
+ "label.tags": "Tags",
67
+ "label.pathGlobs": "Path globs",
68
+ "label.context": "Context",
69
+ "label.mistake": "Mistake",
70
+ "label.fix": "Fix",
71
+ "label.created": "Created",
72
+ "label.updated": "Updated",
73
+ "label.stateKey": "State key",
74
+ "label.supersedes": "Supersedes",
75
+ "label.validFrom": "Valid from",
76
+ "label.validUntil": "Valid until",
77
+ "metric.loadedTraps": "Loaded traps",
78
+ "metric.confirmedTraps": "Confirmed traps",
79
+ "metric.highSeverity": "High severity",
80
+ "metric.topCategory": "Top category",
81
+ "metric.focusArea": "Focus area",
82
+ "metric.mostViewed": "Most viewed",
83
+ "metric.currentFilters": "current filters",
84
+ "metric.selectedScope": "selected scope",
85
+ "metric.errorCritical": "error + critical",
86
+ "metric.repeatedPattern": "repeated pattern",
87
+ "metric.largestPattern": "largest pattern",
88
+ "metric.module": "module",
89
+ "metric.tag": "tag",
90
+ "metric.noHits": "no hits yet",
91
+ "insight.categories": "categories",
92
+ "insight.modules": "modules",
93
+ "insight.tags": "tags",
94
+ "insight.severityMix": "severity mix",
95
+ "option.projectGlobal": "project + global",
96
+ "option.allCategories": "all categories",
97
+ "sort.updated": "recently updated",
98
+ "sort.severity": "severity",
99
+ "sort.hits": "hit count",
100
+ "sort.category": "category",
101
+ "sort.title": "title",
102
+ "sortLabel.updated": "recent first",
103
+ "sortLabel.severity": "severity first",
104
+ "sortLabel.hits": "hits first",
105
+ "sortLabel.category": "category sort",
106
+ "sortLabel.title": "title sort",
107
+ "pill.hits": "{count} hits",
108
+ "pill.candidates": "{count} candidates",
109
+ "pill.accepted": "{count} accepted",
110
+ "pill.warnings": "{count} warnings",
111
+ "pill.quality": "quality {score}",
112
+ "pill.conflict": "conflict {status}",
113
+ "pill.action": "action {action}",
114
+ "review.pending": "pending review",
115
+ "review.rejected": "rejected",
116
+ "review.accepted": "accepted -> trap #{id}",
117
+ "review.acceptedDeleted": "accepted -> trap #{id} deleted",
118
+ "review.acceptedLinkMissing": "accepted -> trap link missing",
119
+ "status.refreshed": "Refreshed",
120
+ "status.candidateSaved": "Candidate saved",
121
+ "status.candidateRejected": "Candidate rejected",
122
+ "status.candidateAccepted": "Candidate accepted",
123
+ "status.possibleConflict": "Possible conflict found",
124
+ "status.supersedesRequired": "Supersedes id is required",
125
+ "status.openedTrap": "Opened trap #{id}",
126
+ "status.trapNotInLibrary": "Trap #{id} is not in the current library",
127
+ "status.sessionDeleted": "Session deleted",
128
+ "status.deletedCandidatesCleaned": "Deleted candidate links cleared",
129
+ "prompt.rejectReason": "Reject reason",
130
+ "prompt.deleteSession": "Delete session {id}?",
131
+ "value.project": "project",
132
+ "value.global": "global",
133
+ "value.active": "active",
134
+ "value.all": "all",
135
+ "value.archived": "archived",
136
+ "value.superseded": "superseded",
137
+ "value.proposed": "proposed",
138
+ "value.accepted": "accepted",
139
+ "value.rejected": "rejected",
140
+ "value.accepted_missing": "accepted missing",
141
+ "value.warning": "warning",
142
+ "value.error": "error",
143
+ "value.critical": "critical",
144
+ "value.api": "api",
145
+ "value.database": "database",
146
+ "value.auth": "auth",
147
+ "value.convention": "convention",
148
+ "value.security": "security",
149
+ "value.performance": "performance",
150
+ "value.bug": "bug",
151
+ "value.other": "other",
152
+ "value.none": "none",
153
+ "value.possible": "possible",
154
+ "value.confirmed": "confirmed",
155
+ "value.accept": "accept",
156
+ "value.edit": "edit",
157
+ "value.supersede": "supersede",
158
+ "value.archive_old": "archive old",
159
+ "value.manual": "manual",
160
+ "value.conversation": "conversation",
161
+ "value.commit": "commit",
162
+ "value.issue": "issue",
163
+ "value.test_failure": "test failure",
164
+ "value.article": "article",
165
+ },
166
+ zh: {
167
+ "app.subtitle": "复盘控制台",
168
+ "nav.review": "审核",
169
+ "nav.library": "库",
170
+ "nav.insights": "洞察",
171
+ "action.refresh": "刷新",
172
+ "action.add": "添加",
173
+ "action.deleteSession": "删除",
174
+ "action.cleanDeletedCandidates": "清除已删除候选",
175
+ "section.sessions": "会话",
176
+ "placeholder.projectPath": "/项目/路径",
177
+ "title.candidateInbox": "候选收件箱",
178
+ "title.candidateDetail": "候选详情",
179
+ "title.trapLibrary": "陷阱库",
180
+ "title.trapDetail": "陷阱详情",
181
+ "title.growthInsights": "成长洞察",
182
+ "title.insightDetail": "洞察详情",
183
+ "title.recentTraps": "最近陷阱",
184
+ "title.mostViewed": "查看最多",
185
+ "title.recentHighSeverity": "最近高严重度",
186
+ "title.evidence": "证据",
187
+ "title.possibleConflicts": "可能冲突",
188
+ "title.before": "修改前",
189
+ "title.after": "修改后",
190
+ "tab.inbox": "待审 {count}",
191
+ "tab.reviewed": "已审 {count}",
192
+ "meta.noProject": "未选择项目",
193
+ "meta.noSession": "未选择会话",
194
+ "meta.sessionCounts": "{goal} / {pending} 个待审,{reviewed} 个已审",
195
+ "meta.libraryCounts": "显示 {shown} / 已加载 {loaded} / {sort}",
196
+ "meta.insightCounts": "{count} 条陷阱 / 状态 {status}",
197
+ "meta.selectCandidate": "选择一个候选",
198
+ "meta.selectTrap": "选择一个陷阱",
199
+ "meta.selectProject": "选择一个项目",
200
+ "empty.noProjects": "没有项目",
201
+ "empty.noSessions": "没有会话",
202
+ "empty.noPending": "没有待审候选",
203
+ "empty.noReviewed": "没有已审候选",
204
+ "empty.noTrapMatches": "没有匹配的陷阱",
205
+ "empty.noTrapSelected": "未选择陷阱",
206
+ "empty.loadingTrapDetails": "正在加载陷阱详情",
207
+ "empty.noCandidateSelected": "未选择候选",
208
+ "empty.noEvidence": "没有证据",
209
+ "empty.noData": "没有数据",
210
+ "empty.noTraps": "没有陷阱",
211
+ "action.viewTrap": "查看陷阱",
212
+ "action.clearFilters": "清除筛选",
213
+ "action.save": "保存",
214
+ "action.accept": "接受",
215
+ "action.reject": "拒绝",
216
+ "action.acceptAnyway": "仍然接受",
217
+ "action.supersede": "标记取代",
218
+ "placeholder.searchTraps": "搜索标题、上下文、错误、修复、标签",
219
+ "placeholder.anyModule": "任意模块",
220
+ "placeholder.anyOwner": "任意负责人",
221
+ "placeholder.supersedesId": "被取代的 id",
222
+ "label.scope": "范围",
223
+ "label.status": "状态",
224
+ "label.category": "分类",
225
+ "label.sort": "排序",
226
+ "label.module": "模块",
227
+ "label.owner": "负责人",
228
+ "label.title": "标题",
229
+ "label.severity": "严重度",
230
+ "label.tags": "标签",
231
+ "label.pathGlobs": "路径规则",
232
+ "label.context": "上下文",
233
+ "label.mistake": "错误",
234
+ "label.fix": "修复",
235
+ "label.created": "创建时间",
236
+ "label.updated": "更新时间",
237
+ "label.stateKey": "状态键",
238
+ "label.supersedes": "取代",
239
+ "label.validFrom": "生效开始",
240
+ "label.validUntil": "生效结束",
241
+ "metric.loadedTraps": "已加载陷阱",
242
+ "metric.confirmedTraps": "确认陷阱",
243
+ "metric.highSeverity": "高严重度",
244
+ "metric.topCategory": "最高分类",
245
+ "metric.focusArea": "关注区域",
246
+ "metric.mostViewed": "查看最多",
247
+ "metric.currentFilters": "当前筛选",
248
+ "metric.selectedScope": "选中范围",
249
+ "metric.errorCritical": "error + critical",
250
+ "metric.repeatedPattern": "重复模式",
251
+ "metric.largestPattern": "最大模式",
252
+ "metric.module": "模块",
253
+ "metric.tag": "标签",
254
+ "metric.noHits": "还没有查看记录",
255
+ "insight.categories": "分类",
256
+ "insight.modules": "模块",
257
+ "insight.tags": "标签",
258
+ "insight.severityMix": "严重度分布",
259
+ "option.projectGlobal": "项目 + 全局",
260
+ "option.allCategories": "全部分类",
261
+ "sort.updated": "最近更新",
262
+ "sort.severity": "严重度",
263
+ "sort.hits": "查看次数",
264
+ "sort.category": "分类",
265
+ "sort.title": "标题",
266
+ "sortLabel.updated": "最近优先",
267
+ "sortLabel.severity": "严重度优先",
268
+ "sortLabel.hits": "查看次数优先",
269
+ "sortLabel.category": "按分类排序",
270
+ "sortLabel.title": "按标题排序",
271
+ "pill.hits": "{count} 次查看",
272
+ "pill.candidates": "{count} 个候选",
273
+ "pill.accepted": "{count} 个已接受",
274
+ "pill.warnings": "{count} 个警告",
275
+ "pill.quality": "质量 {score}",
276
+ "pill.conflict": "冲突 {status}",
277
+ "pill.action": "建议 {action}",
278
+ "review.pending": "待审核",
279
+ "review.rejected": "已拒绝",
280
+ "review.accepted": "已接受 -> 陷阱 #{id}",
281
+ "review.acceptedDeleted": "已接受 -> 陷阱 #{id} 已删除",
282
+ "review.acceptedLinkMissing": "已接受 -> 缺少陷阱链接",
283
+ "status.refreshed": "已刷新",
284
+ "status.candidateSaved": "候选已保存",
285
+ "status.candidateRejected": "候选已拒绝",
286
+ "status.candidateAccepted": "候选已接受",
287
+ "status.possibleConflict": "发现可能冲突",
288
+ "status.supersedesRequired": "需要填写被取代的 id",
289
+ "status.openedTrap": "已打开陷阱 #{id}",
290
+ "status.trapNotInLibrary": "当前陷阱库里没有陷阱 #{id}",
291
+ "status.sessionDeleted": "会话已删除",
292
+ "status.deletedCandidatesCleaned": "已清除删除候选链接",
293
+ "prompt.rejectReason": "拒绝原因",
294
+ "prompt.deleteSession": "删除会话 {id}?",
295
+ "value.project": "项目",
296
+ "value.global": "全局",
297
+ "value.active": "有效",
298
+ "value.all": "全部",
299
+ "value.archived": "已归档",
300
+ "value.superseded": "已取代",
301
+ "value.proposed": "待提议",
302
+ "value.accepted": "已接受",
303
+ "value.rejected": "已拒绝",
304
+ "value.accepted_missing": "接受记录缺失",
305
+ "value.warning": "警告",
306
+ "value.error": "错误",
307
+ "value.critical": "严重",
308
+ "value.api": "API",
309
+ "value.database": "数据库",
310
+ "value.auth": "认证",
311
+ "value.convention": "约定",
312
+ "value.security": "安全",
313
+ "value.performance": "性能",
314
+ "value.bug": "缺陷",
315
+ "value.other": "其他",
316
+ "value.none": "无",
317
+ "value.possible": "可能",
318
+ "value.confirmed": "确认",
319
+ "value.accept": "接受",
320
+ "value.edit": "编辑",
321
+ "value.supersede": "取代",
322
+ "value.archive_old": "归档旧项",
323
+ "value.manual": "手动",
324
+ "value.conversation": "对话",
325
+ "value.commit": "提交",
326
+ "value.issue": "Issue",
327
+ "value.test_failure": "测试失败",
328
+ "value.article": "文章",
329
+ }
330
+ } as const;
331
+
332
+ export type WebLocale = keyof typeof WEB_TEXT;
333
+ export type WebTextKey = keyof typeof WEB_TEXT["en"];
334
+
335
+ export const WEB_TEXT_JSON = JSON.stringify(WEB_TEXT);
package/src/web/server.ts CHANGED
@@ -5,6 +5,7 @@ import { TrapStore } from "../lib/store";
5
5
  import { TrapOperations } from "../lib/trap-operations";
6
6
  import { SessionOperations, type SessionAcceptResult } from "../lib/session-operations";
7
7
  import { SessionStore } from "../lib/session-store";
8
+ import { toListJson, toTrapDetailsJson } from "../lib/output-json";
8
9
  import { WEB_INDEX_HTML } from "./static";
9
10
  import {
10
11
  addWebProject,
@@ -89,6 +90,9 @@ export function createWebHandler(context: WebContext): (request: Request) => Pro
89
90
  if (url.pathname === "/" || url.pathname === "/index.html") {
90
91
  return htmlResponse(WEB_INDEX_HTML);
91
92
  }
93
+ if (url.pathname === "/favicon.ico") {
94
+ return new Response(null, { status: 204 });
95
+ }
92
96
  return jsonResponse({ error: "Not found" }, 404);
93
97
  } catch (error) {
94
98
  const status = error instanceof WebHttpError || error instanceof WebPayloadError ? error.status : 500;
@@ -141,14 +145,14 @@ async function routeApi(request: Request, url: URL, context: WebContext): Promis
141
145
 
142
146
  if (request.method === "GET" && url.pathname === "/api/sessions") {
143
147
  const projectRoot = projectRootFromQuery(url, context);
144
- const sessions = sessionOperations(projectRoot).sessions.listSessions({ status: "all", limit: 100 });
148
+ const sessions = sessionOperations(projectRoot, context.home).sessions.listSessions({ status: "all", limit: 100 });
145
149
  return jsonResponse({ project_root: projectRoot, sessions });
146
150
  }
147
151
 
148
152
  if (request.method === "GET" && url.pathname === "/api/candidates") {
149
153
  const projectRoot = projectRootFromQuery(url, context);
150
154
  const sessionId = requiredQuery(url, "session");
151
- const ops = sessionOperations(projectRoot);
155
+ const ops = sessionOperations(projectRoot, context.home);
152
156
  const session = ops.sessions.showSession(sessionId).session;
153
157
  const document = ops.sessions.candidateDocument(sessionId);
154
158
  return jsonResponse({
@@ -158,19 +162,36 @@ async function routeApi(request: Request, url: URL, context: WebContext): Promis
158
162
  });
159
163
  }
160
164
 
165
+ if (request.method === "GET" && url.pathname === "/api/traps") {
166
+ const projectRoot = projectRootFromQuery(url, context);
167
+ const groups = trapOperations(projectRoot, context.home).listTraps({
168
+ category: optionalQuery(url, "category"),
169
+ scope: optionalQuery(url, "scope"),
170
+ status: optionalQuery(url, "status"),
171
+ module: optionalQuery(url, "module"),
172
+ owner: optionalQuery(url, "owner"),
173
+ limit: optionalNumberQuery(url, "limit"),
174
+ offset: optionalNumberQuery(url, "offset"),
175
+ });
176
+ return jsonResponse({
177
+ project_root: projectRoot,
178
+ traps: toListJson(groups),
179
+ });
180
+ }
181
+
161
182
  if (request.method === "GET" && url.pathname === "/api/trap") {
162
183
  const projectRoot = projectRootFromQuery(url, context);
163
184
  const id = numberQuery(url, "id");
164
185
  const scope = url.searchParams.get("scope") ?? undefined;
165
- const details = trapOperations(projectRoot).getTrapDetails(id, scope);
186
+ const details = trapOperations(projectRoot, context.home).getTrapDetails(id, scope);
166
187
  if (!details) throw new WebHttpError(404, `Trap #${id} not found.`);
167
- return jsonResponse(details);
188
+ return jsonResponse(toTrapDetailsJson(details));
168
189
  }
169
190
 
170
191
  if (request.method === "POST" && url.pathname === "/api/candidate/save") {
171
192
  const body = await readJsonBody(request);
172
193
  const projectRoot = projectRootFromBody(body, context);
173
- const result = sessionOperations(projectRoot).sessions.saveCandidate({
194
+ const result = sessionOperations(projectRoot, context.home).sessions.saveCandidate({
174
195
  candidateId: stringBodyField(body, "candidateId"),
175
196
  sessionId: optionalStringBodyField(body, "sessionId"),
176
197
  edit: recordBodyField(body, "trap"),
@@ -181,7 +202,7 @@ async function routeApi(request: Request, url: URL, context: WebContext): Promis
181
202
  if (request.method === "POST" && url.pathname === "/api/candidate/accept") {
182
203
  const body = await readJsonBody(request);
183
204
  const projectRoot = projectRootFromBody(body, context);
184
- const result = await sessionOperations(projectRoot).sessions.acceptCandidate({
205
+ const result = await sessionOperations(projectRoot, context.home).sessions.acceptCandidate({
185
206
  candidateId: stringBodyField(body, "candidateId"),
186
207
  sessionId: optionalStringBodyField(body, "sessionId"),
187
208
  acceptAnyway: booleanBodyField(body, "acceptAnyway"),
@@ -204,7 +225,7 @@ async function routeApi(request: Request, url: URL, context: WebContext): Promis
204
225
  if (request.method === "POST" && url.pathname === "/api/candidate/reject") {
205
226
  const body = await readJsonBody(request);
206
227
  const projectRoot = projectRootFromBody(body, context);
207
- const result = sessionOperations(projectRoot).sessions.rejectCandidate({
228
+ const result = sessionOperations(projectRoot, context.home).sessions.rejectCandidate({
208
229
  candidateId: stringBodyField(body, "candidateId"),
209
230
  sessionId: optionalStringBodyField(body, "sessionId"),
210
231
  reason: optionalStringBodyField(body, "reason"),
@@ -212,19 +233,44 @@ async function routeApi(request: Request, url: URL, context: WebContext): Promis
212
233
  return jsonResponse({ success: true, session: result.session, candidate: result.candidate });
213
234
  }
214
235
 
236
+ if (request.method === "POST" && url.pathname === "/api/session/delete") {
237
+ const body = await readJsonBody(request);
238
+ const projectRoot = projectRootFromBody(body, context);
239
+ const result = sessionOperations(projectRoot, context.home).sessions.deleteSession(
240
+ stringBodyField(body, "sessionId")
241
+ );
242
+ return jsonResponse({ success: true, ...result });
243
+ }
244
+
245
+ if (request.method === "POST" && url.pathname === "/api/session/cleanup") {
246
+ const body = await readJsonBody(request);
247
+ const projectRoot = projectRootFromBody(body, context);
248
+ const ops = sessionOperations(projectRoot, context.home);
249
+ const result = ops.sessions.cleanupDeletedTrapCandidates(
250
+ optionalStringBodyField(body, "sessionId")
251
+ );
252
+ return jsonResponse({
253
+ success: true,
254
+ session: result.session,
255
+ removed_count: result.removed_count,
256
+ removed_candidate_ids: result.removed_candidate_ids,
257
+ candidates: webCandidates(result.candidates, ops.traps),
258
+ });
259
+ }
260
+
215
261
  throw new WebHttpError(404, "Not found");
216
262
  }
217
263
 
218
- function sessionOperations(projectRoot: string): { traps: TrapOperations; sessions: SessionOperations } {
219
- const traps = trapOperations(projectRoot);
264
+ function sessionOperations(projectRoot: string, home?: string): { traps: TrapOperations; sessions: SessionOperations } {
265
+ const traps = trapOperations(projectRoot, home);
220
266
  return {
221
267
  traps,
222
268
  sessions: new SessionOperations(new SessionStore(projectRoot), traps),
223
269
  };
224
270
  }
225
271
 
226
- function trapOperations(projectRoot: string): TrapOperations {
227
- return new TrapOperations(new TrapStore(projectRoot));
272
+ function trapOperations(projectRoot: string, home?: string): TrapOperations {
273
+ return new TrapOperations(new TrapStore(projectRoot, undefined, home));
228
274
  }
229
275
 
230
276
  function webCandidates(candidates: CandidateTrap[], traps: TrapOperations): WebCandidate[] {
@@ -353,6 +399,19 @@ function numberQuery(url: URL, key: string): number {
353
399
  return value;
354
400
  }
355
401
 
402
+ function optionalQuery(url: URL, key: string): string | undefined {
403
+ const value = url.searchParams.get(key)?.trim();
404
+ return value || undefined;
405
+ }
406
+
407
+ function optionalNumberQuery(url: URL, key: string): number | undefined {
408
+ const value = optionalQuery(url, key);
409
+ if (!value) return undefined;
410
+ const parsed = Number.parseInt(value, 10);
411
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new WebHttpError(400, `${key} must be a positive integer.`);
412
+ return parsed;
413
+ }
414
+
356
415
  function stringBodyField(body: Record<string, unknown>, key: string): string {
357
416
  const value = optionalStringBodyField(body, key);
358
417
  if (!value) throw new WebHttpError(400, `${key} is required.`);