harnery 0.12.2 → 0.13.0
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.
- package/README.md +1 -1
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +193 -0
- package/dist/core/policy/types.d.ts +1 -1
- package/dist/core/policy/types.d.ts.map +1 -1
- package/dist/core/workflow/approvals.d.ts +71 -0
- package/dist/core/workflow/approvals.d.ts.map +1 -0
- package/dist/core/workflow/approvals.js +288 -0
- package/dist/core/workflow/engine.d.ts +6 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +224 -43
- package/dist/core/workflow/index.d.ts +6 -2
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +3 -1
- package/dist/core/workflow/run-state.d.ts +43 -0
- package/dist/core/workflow/run-state.d.ts.map +1 -0
- package/dist/core/workflow/run-state.js +231 -0
- package/dist/core/workflow/types.d.ts +9 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/commands/workflow.ts +226 -0
- package/src/core/policy/types.ts +1 -1
- package/src/core/workflow/approvals.ts +393 -0
- package/src/core/workflow/engine.ts +268 -44
- package/src/core/workflow/index.ts +31 -1
- package/src/core/workflow/run-state.ts +287 -0
- package/src/core/workflow/types.ts +9 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
linkSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
statSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import type { PolicyEvaluation, PolicyRequestSummary, PolicyVerdict } from "../policy/index.ts";
|
|
15
|
+
|
|
16
|
+
export const WORKFLOW_APPROVAL_SCHEMA_VERSION = 1 as const;
|
|
17
|
+
|
|
18
|
+
const RECORD_LIMIT_BYTES = 128 * 1024;
|
|
19
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
20
|
+
const APPROVAL_ID = /^apr-[a-f0-9]{20}$/;
|
|
21
|
+
const DECISION_ID = /^p[1-9][0-9]{0,4}$/;
|
|
22
|
+
const MAX_ACTOR = 200;
|
|
23
|
+
const MAX_REASON = 2_000;
|
|
24
|
+
const MAX_ADDRESSEE = 200;
|
|
25
|
+
|
|
26
|
+
export type WorkflowApprovalStatus = "pending" | "approved" | "denied";
|
|
27
|
+
|
|
28
|
+
export interface WorkflowApprovalRequest {
|
|
29
|
+
schema_version: typeof WORKFLOW_APPROVAL_SCHEMA_VERSION;
|
|
30
|
+
id: string;
|
|
31
|
+
run_id: string;
|
|
32
|
+
decision_id: string;
|
|
33
|
+
requested_at: string;
|
|
34
|
+
addressed_to: string;
|
|
35
|
+
policy: { name: string; sha256: string };
|
|
36
|
+
initial_verdict: Extract<PolicyVerdict, "ask">;
|
|
37
|
+
request: PolicyRequestSummary;
|
|
38
|
+
evaluation: PolicyEvaluation;
|
|
39
|
+
request_sha256: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WorkflowApprovalDecision {
|
|
43
|
+
schema_version: typeof WORKFLOW_APPROVAL_SCHEMA_VERSION;
|
|
44
|
+
approval_id: string;
|
|
45
|
+
run_id: string;
|
|
46
|
+
verdict: "allow" | "deny";
|
|
47
|
+
actor: string;
|
|
48
|
+
reason?: string;
|
|
49
|
+
decided_at: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface WorkflowApproval {
|
|
53
|
+
request: WorkflowApprovalRequest;
|
|
54
|
+
decision?: WorkflowApprovalDecision;
|
|
55
|
+
status: WorkflowApprovalStatus;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CreateWorkflowApprovalInput {
|
|
59
|
+
coordRoot: string;
|
|
60
|
+
runId: string;
|
|
61
|
+
decisionId: string;
|
|
62
|
+
addressedTo?: string;
|
|
63
|
+
policy: { name: string; sha256: string };
|
|
64
|
+
request: PolicyRequestSummary;
|
|
65
|
+
evaluation: PolicyEvaluation;
|
|
66
|
+
now?: () => string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ResolveWorkflowApprovalInput {
|
|
70
|
+
coordRoot: string;
|
|
71
|
+
approvalId: string;
|
|
72
|
+
verdict: "allow" | "deny";
|
|
73
|
+
actor: string;
|
|
74
|
+
reason?: string;
|
|
75
|
+
now?: () => string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function workflowApprovalId(runId: string, decisionId: string): string {
|
|
79
|
+
assertRunId(runId);
|
|
80
|
+
if (!DECISION_ID.test(decisionId)) {
|
|
81
|
+
throw new Error(`invalid workflow policy decision id ${JSON.stringify(decisionId)}`);
|
|
82
|
+
}
|
|
83
|
+
return `apr-${createHash("sha256").update(`${runId}\0${decisionId}`).digest("hex").slice(0, 20)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createWorkflowApproval(input: CreateWorkflowApprovalInput): {
|
|
87
|
+
approval: WorkflowApproval;
|
|
88
|
+
created: boolean;
|
|
89
|
+
} {
|
|
90
|
+
const id = workflowApprovalId(input.runId, input.decisionId);
|
|
91
|
+
const addressedTo = bounded(input.addressedTo ?? "operator", "approval addressee", MAX_ADDRESSEE);
|
|
92
|
+
const requestSha256 = approvalRequestDigest(input.policy.sha256, input.request, input.evaluation);
|
|
93
|
+
const record: WorkflowApprovalRequest = {
|
|
94
|
+
schema_version: WORKFLOW_APPROVAL_SCHEMA_VERSION,
|
|
95
|
+
id,
|
|
96
|
+
run_id: input.runId,
|
|
97
|
+
decision_id: input.decisionId,
|
|
98
|
+
requested_at: (input.now ?? (() => new Date().toISOString()))(),
|
|
99
|
+
addressed_to: addressedTo,
|
|
100
|
+
policy: {
|
|
101
|
+
name: bounded(input.policy.name, "approval policy name", MAX_ACTOR),
|
|
102
|
+
sha256: sha256(input.policy.sha256, "approval policy sha256"),
|
|
103
|
+
},
|
|
104
|
+
initial_verdict: "ask",
|
|
105
|
+
request: input.request,
|
|
106
|
+
evaluation: input.evaluation,
|
|
107
|
+
request_sha256: requestSha256,
|
|
108
|
+
};
|
|
109
|
+
const dir = approvalDir(input.coordRoot, id);
|
|
110
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
111
|
+
chmodSync(dir, 0o700);
|
|
112
|
+
const created = writeExclusiveJson(join(dir, "request.json"), record);
|
|
113
|
+
const approval = readWorkflowApproval(input.coordRoot, id);
|
|
114
|
+
if (
|
|
115
|
+
approval.request.run_id !== record.run_id ||
|
|
116
|
+
approval.request.decision_id !== record.decision_id ||
|
|
117
|
+
approval.request.request_sha256 !== record.request_sha256 ||
|
|
118
|
+
approval.request.policy.sha256 !== record.policy.sha256
|
|
119
|
+
) {
|
|
120
|
+
throw new Error(`approval ${id} already exists for a different policy request`);
|
|
121
|
+
}
|
|
122
|
+
return { approval, created };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resolveWorkflowApproval(input: ResolveWorkflowApprovalInput): {
|
|
126
|
+
approval: WorkflowApproval;
|
|
127
|
+
applied: boolean;
|
|
128
|
+
} {
|
|
129
|
+
const approval = readWorkflowApproval(input.coordRoot, input.approvalId);
|
|
130
|
+
const decision: WorkflowApprovalDecision = {
|
|
131
|
+
schema_version: WORKFLOW_APPROVAL_SCHEMA_VERSION,
|
|
132
|
+
approval_id: approval.request.id,
|
|
133
|
+
run_id: approval.request.run_id,
|
|
134
|
+
verdict: input.verdict,
|
|
135
|
+
actor: bounded(input.actor, "approval actor", MAX_ACTOR),
|
|
136
|
+
reason:
|
|
137
|
+
input.reason === undefined ? undefined : bounded(input.reason, "approval reason", MAX_REASON),
|
|
138
|
+
decided_at: (input.now ?? (() => new Date().toISOString()))(),
|
|
139
|
+
};
|
|
140
|
+
const applied = writeExclusiveJson(
|
|
141
|
+
join(approvalDir(input.coordRoot, input.approvalId), "decision.json"),
|
|
142
|
+
decision,
|
|
143
|
+
);
|
|
144
|
+
const resolved = readWorkflowApproval(input.coordRoot, input.approvalId);
|
|
145
|
+
if (resolved.decision?.verdict !== input.verdict) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`approval ${input.approvalId} is already ${resolved.status}; conflicting ${input.verdict === "allow" ? "approval" : "denial"} refused`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (applied) appendResolutionJournal(input.coordRoot, resolved);
|
|
151
|
+
return { approval: resolved, applied };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function readWorkflowApproval(coordRoot: string, approvalId: string): WorkflowApproval {
|
|
155
|
+
assertApprovalId(approvalId);
|
|
156
|
+
const dir = approvalDir(coordRoot, approvalId);
|
|
157
|
+
const request = readJsonRecord<WorkflowApprovalRequest>(join(dir, "request.json"));
|
|
158
|
+
validateRequest(request, approvalId);
|
|
159
|
+
const decisionPath = join(dir, "decision.json");
|
|
160
|
+
const decision = existsSync(decisionPath)
|
|
161
|
+
? readJsonRecord<WorkflowApprovalDecision>(decisionPath)
|
|
162
|
+
: undefined;
|
|
163
|
+
if (decision) validateDecision(decision, request);
|
|
164
|
+
return {
|
|
165
|
+
request,
|
|
166
|
+
decision,
|
|
167
|
+
status: decision?.verdict === "allow" ? "approved" : decision ? "denied" : "pending",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function listWorkflowApprovals(
|
|
172
|
+
coordRoot: string,
|
|
173
|
+
options: { status?: WorkflowApprovalStatus; runId?: string } = {},
|
|
174
|
+
): WorkflowApproval[] {
|
|
175
|
+
if (options.runId) assertRunId(options.runId);
|
|
176
|
+
const root = approvalsRoot(coordRoot);
|
|
177
|
+
if (!existsSync(root)) return [];
|
|
178
|
+
const records: WorkflowApproval[] = [];
|
|
179
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
180
|
+
if (!entry.isDirectory() || !APPROVAL_ID.test(entry.name)) continue;
|
|
181
|
+
try {
|
|
182
|
+
const approval = readWorkflowApproval(coordRoot, entry.name);
|
|
183
|
+
if (options.status && approval.status !== options.status) continue;
|
|
184
|
+
if (options.runId && approval.request.run_id !== options.runId) continue;
|
|
185
|
+
records.push(approval);
|
|
186
|
+
} catch {
|
|
187
|
+
// A malformed record fails closed when addressed directly, but one bad
|
|
188
|
+
// directory must not hide the rest of the operator's inbox.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return records.sort((a, b) => b.request.requested_at.localeCompare(a.request.requested_at));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function renderWorkflowApproval(approval: WorkflowApproval): string {
|
|
195
|
+
const lines = [
|
|
196
|
+
`${approval.request.id}: ${approval.status}`,
|
|
197
|
+
`run: ${approval.request.run_id}`,
|
|
198
|
+
`addressed to: ${approval.request.addressed_to}`,
|
|
199
|
+
`requested: ${approval.request.requested_at}`,
|
|
200
|
+
`policy: ${approval.request.policy.name}`,
|
|
201
|
+
`phase: ${approval.request.request.phase}`,
|
|
202
|
+
`action: ${approval.request.request.action}`,
|
|
203
|
+
`reason: ${approval.request.evaluation.reason}`,
|
|
204
|
+
];
|
|
205
|
+
if (approval.decision) {
|
|
206
|
+
lines.push(`decided: ${approval.decision.decided_at} by ${approval.decision.actor}`);
|
|
207
|
+
if (approval.decision.reason) lines.push(`decision reason: ${approval.decision.reason}`);
|
|
208
|
+
}
|
|
209
|
+
return `${lines.join("\n")}\n`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function assertWorkflowRunId(runId: string): void {
|
|
213
|
+
assertRunId(runId);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function appendResolutionJournal(coordRoot: string, approval: WorkflowApproval): void {
|
|
217
|
+
const journalPath = join(
|
|
218
|
+
coordRoot,
|
|
219
|
+
".harnery",
|
|
220
|
+
"workflows",
|
|
221
|
+
approval.request.run_id,
|
|
222
|
+
"journal.jsonl",
|
|
223
|
+
);
|
|
224
|
+
if (!existsSync(journalPath) || !approval.decision) return;
|
|
225
|
+
const event = {
|
|
226
|
+
schema_version: 1,
|
|
227
|
+
run_id: approval.request.run_id,
|
|
228
|
+
ts: approval.decision.decided_at,
|
|
229
|
+
event: "approval.resolved",
|
|
230
|
+
stage: "",
|
|
231
|
+
approval_id: approval.request.id,
|
|
232
|
+
decision_id: approval.request.decision_id,
|
|
233
|
+
verdict: approval.decision.verdict,
|
|
234
|
+
actor: approval.decision.actor,
|
|
235
|
+
reason: approval.decision.reason ?? null,
|
|
236
|
+
};
|
|
237
|
+
writeFileSync(journalPath, `${JSON.stringify(event)}\n`, { encoding: "utf8", flag: "a" });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function approvalRequestDigest(
|
|
241
|
+
policySha256: string,
|
|
242
|
+
request: PolicyRequestSummary,
|
|
243
|
+
evaluation: PolicyEvaluation,
|
|
244
|
+
): string {
|
|
245
|
+
return createHash("sha256")
|
|
246
|
+
.update(JSON.stringify({ policy_sha256: policySha256, request, evaluation }))
|
|
247
|
+
.digest("hex");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function writeExclusiveJson(path: string, value: unknown): boolean {
|
|
251
|
+
const body = `${JSON.stringify(value, null, 2)}\n`;
|
|
252
|
+
if (Buffer.byteLength(body) > RECORD_LIMIT_BYTES) {
|
|
253
|
+
throw new Error(`approval record exceeds ${RECORD_LIMIT_BYTES} bytes`);
|
|
254
|
+
}
|
|
255
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
256
|
+
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
257
|
+
try {
|
|
258
|
+
writeFileSync(temporary, body, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
259
|
+
try {
|
|
260
|
+
linkSync(temporary, path);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
chmodSync(path, 0o600);
|
|
266
|
+
return true;
|
|
267
|
+
} finally {
|
|
268
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function readJsonRecord<T>(path: string): T {
|
|
273
|
+
if (!existsSync(path)) throw new Error(`approval record not found at ${path}`);
|
|
274
|
+
const bytes = statSync(path).size;
|
|
275
|
+
if (bytes <= 0 || bytes > RECORD_LIMIT_BYTES) {
|
|
276
|
+
throw new Error(`approval record at ${path} has invalid size ${bytes}`);
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
throw new Error(`cannot parse approval record at ${path}: ${(error as Error).message}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function validateRequest(request: WorkflowApprovalRequest, approvalId: string): void {
|
|
286
|
+
if (
|
|
287
|
+
request.schema_version !== WORKFLOW_APPROVAL_SCHEMA_VERSION ||
|
|
288
|
+
request.id !== approvalId ||
|
|
289
|
+
!RUN_ID.test(request.run_id) ||
|
|
290
|
+
!DECISION_ID.test(request.decision_id) ||
|
|
291
|
+
workflowApprovalId(request.run_id, request.decision_id) !== approvalId ||
|
|
292
|
+
!validTimestamp(request.requested_at) ||
|
|
293
|
+
bounded(request.addressed_to, "approval addressee", MAX_ADDRESSEE) !== request.addressed_to ||
|
|
294
|
+
bounded(request.policy?.name, "approval policy name", MAX_ACTOR) !== request.policy.name ||
|
|
295
|
+
request.initial_verdict !== "ask" ||
|
|
296
|
+
!/^[a-f0-9]{64}$/.test(request.request_sha256) ||
|
|
297
|
+
!/^[a-f0-9]{64}$/.test(request.policy?.sha256) ||
|
|
298
|
+
!validPolicyRequest(request.request) ||
|
|
299
|
+
!validPolicyEvaluation(request.evaluation) ||
|
|
300
|
+
approvalRequestDigest(request.policy.sha256, request.request, request.evaluation) !==
|
|
301
|
+
request.request_sha256
|
|
302
|
+
) {
|
|
303
|
+
throw new Error(`approval ${approvalId} has an unsupported or mismatched request record`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function validateDecision(
|
|
308
|
+
decision: WorkflowApprovalDecision,
|
|
309
|
+
request: WorkflowApprovalRequest,
|
|
310
|
+
): void {
|
|
311
|
+
if (
|
|
312
|
+
decision.schema_version !== WORKFLOW_APPROVAL_SCHEMA_VERSION ||
|
|
313
|
+
decision.approval_id !== request.id ||
|
|
314
|
+
decision.run_id !== request.run_id ||
|
|
315
|
+
(decision.verdict !== "allow" && decision.verdict !== "deny") ||
|
|
316
|
+
bounded(decision.actor, "approval actor", MAX_ACTOR) !== decision.actor ||
|
|
317
|
+
(decision.reason !== undefined &&
|
|
318
|
+
bounded(decision.reason, "approval reason", MAX_REASON) !== decision.reason) ||
|
|
319
|
+
!validTimestamp(decision.decided_at)
|
|
320
|
+
) {
|
|
321
|
+
throw new Error(`approval ${request.id} has an unsupported or mismatched decision record`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function validPolicyRequest(request: PolicyRequestSummary): boolean {
|
|
326
|
+
if (!request || typeof request !== "object") return false;
|
|
327
|
+
return (
|
|
328
|
+
(request.phase === "dispatch" || request.phase === "external_mutation") &&
|
|
329
|
+
typeof request.action === "string" &&
|
|
330
|
+
request.action.length > 0 &&
|
|
331
|
+
["shared", "worktree", "sandbox", "remote"].includes(request.isolation) &&
|
|
332
|
+
["enabled", "disabled", "unknown"].includes(request.network_access)
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function validPolicyEvaluation(evaluation: PolicyEvaluation): boolean {
|
|
337
|
+
if (!evaluation || typeof evaluation !== "object") return false;
|
|
338
|
+
return (
|
|
339
|
+
evaluation.verdict === "ask" &&
|
|
340
|
+
typeof evaluation.reason === "string" &&
|
|
341
|
+
evaluation.reason.length > 0 &&
|
|
342
|
+
Array.isArray(evaluation.rules) &&
|
|
343
|
+
evaluation.rules.every(
|
|
344
|
+
(rule) =>
|
|
345
|
+
rule &&
|
|
346
|
+
typeof rule.code === "string" &&
|
|
347
|
+
(rule.verdict === "allow" || rule.verdict === "ask" || rule.verdict === "deny") &&
|
|
348
|
+
typeof rule.reason === "string",
|
|
349
|
+
)
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function validTimestamp(value: unknown): value is string {
|
|
354
|
+
return typeof value === "string" && value.length <= 40 && Number.isFinite(Date.parse(value));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function approvalsRoot(coordRoot: string): string {
|
|
358
|
+
return join(coordRoot, ".harnery", "approvals");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function approvalDir(coordRoot: string, approvalId: string): string {
|
|
362
|
+
assertApprovalId(approvalId);
|
|
363
|
+
return join(approvalsRoot(coordRoot), approvalId);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function assertApprovalId(approvalId: string): void {
|
|
367
|
+
if (!APPROVAL_ID.test(approvalId)) {
|
|
368
|
+
throw new Error(`invalid workflow approval id ${JSON.stringify(approvalId)}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function assertRunId(runId: string): void {
|
|
373
|
+
if (!RUN_ID.test(runId)) throw new Error(`invalid workflow run id ${JSON.stringify(runId)}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function sha256(value: string, field: string): string {
|
|
377
|
+
if (!/^[a-f0-9]{64}$/.test(value)) throw new Error(`${field} must be a SHA-256 digest`);
|
|
378
|
+
return value;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function bounded(value: unknown, field: string, max: number): string {
|
|
382
|
+
if (typeof value !== "string") throw new Error(`${field} must be a string`);
|
|
383
|
+
const normalized = Array.from(value, (character) => {
|
|
384
|
+
const code = character.charCodeAt(0);
|
|
385
|
+
return code < 32 || code === 127 ? " " : character;
|
|
386
|
+
})
|
|
387
|
+
.join("")
|
|
388
|
+
.replace(/\s+/g, " ")
|
|
389
|
+
.trim();
|
|
390
|
+
if (!normalized) throw new Error(`${field} must not be empty`);
|
|
391
|
+
if (normalized.length > max) throw new Error(`${field} exceeds ${max} characters`);
|
|
392
|
+
return normalized;
|
|
393
|
+
}
|