filegrc 0.3.4 → 0.5.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/src/server.js CHANGED
@@ -1,25 +1,56 @@
1
1
  import { createServer as createHttpServer } from "node:http";
2
- import { readFile, realpath } from "node:fs/promises";
3
- import { extname, resolve } from "node:path";
2
+ import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { extname, join, resolve } from "node:path";
4
5
  import { getResourceDefinition } from "../model/index.js";
5
6
  import { prepareAuditWorkspace } from "./audit-preparation.js";
7
+ import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
8
+ import { applyApplicabilityReview, planApplicabilityReview } from "./batch-review.js";
9
+ import { applyCollectionReview, planCollectionReview } from "./collection-review.js";
6
10
  import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet.js";
7
- import { ensureEvidenceTestDrafts } from "./evidence-tests.js";
8
11
  import { FAVICON_PNG, LOGO_MARK_PNG } from "./favicon.js";
9
- import { createResource, deleteResource, updateContent, updateResource } from "./files.js";
10
12
  import {
13
+ addEvidenceAttachment,
14
+ createResource,
15
+ deleteResource,
16
+ removeEvidenceAttachment,
17
+ updateContent,
18
+ updateResource
19
+ } from "./files.js";
20
+ import {
21
+ BROWSER_VALIDATION,
11
22
  commitAndPushWorkspace,
12
23
  getBrowserRepositoryState,
13
24
  getFileHistory,
25
+ getGitSummary,
14
26
  pullWorkspace,
15
27
  pushWorkspace,
16
28
  retryBrowserSync,
17
29
  runBrowserMutation
18
30
  } from "./git.js";
19
- import { completeObligationOccurrence, createObligationEvent, planObligations } from "./obligations.js";
31
+ import { normalizeResourceMutation, serializeWorkspaceMutation } from "./mutation.js";
32
+ import {
33
+ completeObligationAction,
34
+ completeObligationEvent,
35
+ completeObligationOccurrence,
36
+ createObligationEvent,
37
+ planObligations
38
+ } from "./obligations.js";
39
+ import {
40
+ planExternalReviewerGovernance,
41
+ setupExternalReviewerGovernance
42
+ } from "./external-reviewer.js";
20
43
  import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
21
- import { createAppState } from "./state.js";
44
+ import { applyReconciliation, planReconciliation } from "./reconciliation.js";
45
+ import { createAppState, createResourceDetail } from "./state.js";
22
46
  import { setupWorkspace } from "./setup.js";
47
+ import { collectTimings, measureTiming, timingEnabled } from "./timing.js";
48
+ import {
49
+ assessWorkflow,
50
+ buildWorkflowDelta,
51
+ previewWorkflowMutation,
52
+ workflowForResource
53
+ } from "./workflow.js";
23
54
  import { loadWorkspace } from "./workspace.js";
24
55
  import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
25
56
 
@@ -34,8 +65,17 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
34
65
  return json(response, 403, { error: "Cross-origin writes are not allowed." });
35
66
  }
36
67
  if (request.method === "GET" && url.pathname === "/api/state") {
68
+ if (timingEnabled()) {
69
+ const { result, timings } = await collectTimings(() => measureTiming("state", () => createAppState(input, {
70
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
71
+ includeDetails: false
72
+ })));
73
+ console.error(`[filegrc timing] ${JSON.stringify({ operation: "state", ...timings })}`);
74
+ return json(response, 200, result);
75
+ }
37
76
  return json(response, 200, await createAppState(input, {
38
- allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
77
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
78
+ includeDetails: false
39
79
  }));
40
80
  }
41
81
  if (request.method === "GET" && url.pathname === "/api/history") {
@@ -50,9 +90,84 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
50
90
  from: url.searchParams.get("from") || undefined,
51
91
  through: url.searchParams.get("through") || undefined,
52
92
  now: url.searchParams.get("now") || undefined,
93
+ includeComplete: url.searchParams.get("includeComplete") === "true",
94
+ model: loaded.model
95
+ }));
96
+ }
97
+ if (request.method === "GET" && url.pathname === "/api/workflow") {
98
+ const start = url.searchParams.get("start");
99
+ const end = url.searchParams.get("end");
100
+ return json(response, 200, await assessWorkflow(input, {
101
+ auditId: url.searchParams.get("auditId") || undefined,
102
+ asOf: url.searchParams.get("asOf") || undefined,
103
+ through: url.searchParams.get("through") || undefined,
104
+ coverage: start || end ? { kind: "range", startsOn: start, endsOn: end } : undefined,
53
105
  includeComplete: url.searchParams.get("includeComplete") === "true"
54
106
  }));
55
107
  }
108
+ if (request.method === "POST" && url.pathname === "/api/workflow/preview") {
109
+ return json(response, 200, await previewWorkflowMutation(input, await readJson(request)));
110
+ }
111
+ if (request.method === "GET" && url.pathname === "/api/reconciliation") {
112
+ return json(response, 200, await planReconciliation(input));
113
+ }
114
+ if (request.method === "POST" && url.pathname === "/api/reconciliation") {
115
+ const payload = await readJson(request);
116
+ return json(response, 201, await browserMutation(input, options, {
117
+ message: (result) => `Reconcile policy event: ${result.event?.title || payload.candidateId}`
118
+ }, () => applyReconciliation(input, {
119
+ ...payload,
120
+ confirmed: payload.confirmed === true
121
+ })));
122
+ }
123
+ if (request.method === "POST" && url.pathname === "/api/external-reviewer-governance/preview") {
124
+ return json(response, 200, await planExternalReviewerGovernance(input, await readJson(request)));
125
+ }
126
+ if (request.method === "POST" && url.pathname === "/api/external-reviewer-governance") {
127
+ const payload = await readJson(request);
128
+ return json(response, 201, await browserMutation(input, options, {
129
+ message: (result) => `Assign external independent reviewer: ${result.reviewerId}`
130
+ }, () => setupExternalReviewerGovernance(input, {
131
+ ...payload,
132
+ confirmed: payload.confirmed === true
133
+ })));
134
+ }
135
+ if (request.method === "POST" && url.pathname === "/api/audit-cycle/preview") {
136
+ return json(response, 200, await planNextAuditCycle(input, await readJson(request)));
137
+ }
138
+ if (request.method === "POST" && url.pathname === "/api/audit-cycle") {
139
+ const payload = await readJson(request);
140
+ return json(response, 201, await browserMutation(input, options, {
141
+ message: (result) => `Create next audit cycle: ${result.audit?.title || payload.priorAuditId}`
142
+ }, () => createNextAuditCycle(input, {
143
+ ...payload,
144
+ confirmed: payload.confirmed === true
145
+ })));
146
+ }
147
+ if (request.method === "POST" && url.pathname === "/api/applicability-review/preview") {
148
+ return json(response, 200, await planApplicabilityReview(input, await readJson(request)));
149
+ }
150
+ if (request.method === "POST" && url.pathname === "/api/applicability-review") {
151
+ const payload = await readJson(request);
152
+ return json(response, 201, await browserMutation(input, options, {
153
+ message: (result) => `Record ${result.reviewedIds?.length || 0} applicability decisions`
154
+ }, () => applyApplicabilityReview(input, {
155
+ ...payload,
156
+ confirmed: payload.confirmed === true
157
+ })));
158
+ }
159
+ if (request.method === "POST" && url.pathname === "/api/collection-review/preview") {
160
+ return json(response, 200, await planCollectionReview(input, await readJson(request)));
161
+ }
162
+ if (request.method === "POST" && url.pathname === "/api/collection-review") {
163
+ const payload = await readJson(request);
164
+ return json(response, 201, await browserMutation(input, options, {
165
+ message: (result) => `Confirm ${result.assessment?.configuration?.title || payload.resourceType}`
166
+ }, () => applyCollectionReview(input, {
167
+ ...payload,
168
+ confirmed: payload.confirmed === true
169
+ })));
170
+ }
56
171
  if (request.method === "POST" && url.pathname === "/api/obligation-events") {
57
172
  const payload = await readJson(request);
58
173
  return json(response, 201, await browserMutation(input, options, {
@@ -68,10 +183,77 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
68
183
  obligationId: payload.obligationId,
69
184
  record: payload.record,
70
185
  content: payload.content,
71
- expectedRevision: payload.revision
186
+ expectedRevision: requireRevision(payload.revision, `obligation/${payload.obligationId}`)
72
187
  }));
73
188
  return json(response, 201, result);
74
189
  }
190
+ if (request.method === "POST" && url.pathname === "/api/action-completions") {
191
+ const payload = await readJson(request);
192
+ if (!safeSegment(payload.actionItemId)) {
193
+ return json(response, 400, { error: "A safe Action Item ID is required." });
194
+ }
195
+ const result = await browserMutation(input, options, {
196
+ message: () => `Complete action item: ${payload.actionItemId}`
197
+ }, () => completeObligationAction(input, {
198
+ actionItemId: payload.actionItemId,
199
+ completedOn: payload.completedOn,
200
+ record: payload.record,
201
+ content: payload.content,
202
+ expectedRevision: requireRevision(payload.revision, `action-item/${payload.actionItemId}`)
203
+ }));
204
+ return json(response, 201, result);
205
+ }
206
+ if (request.method === "POST" && url.pathname === "/api/obligation-event-completions") {
207
+ const payload = await readJson(request);
208
+ if (!safeSegment(payload.eventId)) {
209
+ return json(response, 400, { error: "A safe Policy Event ID is required." });
210
+ }
211
+ const result = await browserMutation(input, options, {
212
+ message: () => `Complete policy event: ${payload.eventId}`
213
+ }, () => completeObligationEvent(input, {
214
+ eventId: payload.eventId,
215
+ completedOn: payload.completedOn,
216
+ expectedRevision: requireRevision(payload.revision, `obligation-event/${payload.eventId}`)
217
+ }));
218
+ return json(response, 200, result);
219
+ }
220
+ const attachmentMatch = /^\/api\/evidence-attachments\/([^/]+)\/([^/]+)$/.exec(url.pathname);
221
+ if (attachmentMatch && request.method === "POST") {
222
+ const evidenceId = decodeURIComponent(attachmentMatch[1]);
223
+ const attachment = decodeURIComponent(attachmentMatch[2]);
224
+ if (!safeSegment(evidenceId) || !safeAttachmentName(attachment)) {
225
+ return json(response, 400, { error: "Safe Evidence and attachment identifiers are required." });
226
+ }
227
+ const revision = requireRevision(url.searchParams.get("revision"), `evidence/${evidenceId}`);
228
+ const temporaryDirectory = await mkdtemp(join(tmpdir(), "filegrc-evidence-upload-"));
229
+ try {
230
+ const temporaryPath = join(temporaryDirectory, attachment);
231
+ await writeFile(temporaryPath, await readBytes(request));
232
+ const result = await browserMutation(input, options, {
233
+ message: () => `Attach evidence file: ${attachment}`
234
+ }, () => addEvidenceAttachment(input, evidenceId, temporaryPath, {
235
+ name: attachment,
236
+ expectedRevision: revision
237
+ }));
238
+ return json(response, 201, result);
239
+ } finally {
240
+ await rm(temporaryDirectory, { recursive: true, force: true });
241
+ }
242
+ }
243
+ if (attachmentMatch && request.method === "DELETE") {
244
+ const evidenceId = decodeURIComponent(attachmentMatch[1]);
245
+ const attachment = decodeURIComponent(attachmentMatch[2]);
246
+ if (!safeSegment(evidenceId) || !safeAttachmentName(attachment)) {
247
+ return json(response, 400, { error: "Safe Evidence and attachment identifiers are required." });
248
+ }
249
+ const revision = requireRevision(url.searchParams.get("revision"), `evidence/${evidenceId}`);
250
+ const result = await browserMutation(input, options, {
251
+ message: () => `Remove evidence file: ${attachment}`
252
+ }, () => removeEvidenceAttachment(input, evidenceId, attachment, {
253
+ expectedRevision: revision
254
+ }));
255
+ return json(response, 200, result);
256
+ }
75
257
  if (request.method === "GET" && url.pathname === "/api/evidence-packet") {
76
258
  return json(response, 200, await prepareEvidencePacket(input, {
77
259
  start: url.searchParams.get("start"),
@@ -102,49 +284,66 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
102
284
  message: () => `Prepare audit: ${payload.auditId || "engagement"}`
103
285
  }, () => prepareAuditWorkspace(input, payload)));
104
286
  }
105
- if (request.method === "POST" && url.pathname === "/api/evidence-test-drafts") {
106
- return json(response, 201, await browserMutation(input, options, {
107
- message: "Create evidence collection test drafts"
108
- }, () => ensureEvidenceTestDrafts(input)));
109
- }
110
287
  if (request.method === "POST" && url.pathname === "/api/setup") {
111
288
  const payload = await readJson(request);
112
- return json(response, 200, await browserMutation(input, options, {
113
- message: (result) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${result.workspace.organizationName}`
114
- }, () => setupWorkspace(input, payload)));
289
+ const completeSetup = async () => {
290
+ return browserMutation(input, options, {
291
+ message: (setupResult) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${setupResult.workspace.organizationName}`
292
+ }, () => setupWorkspace(input, payload));
293
+ };
294
+ return json(response, 200, await completeSetup());
115
295
  }
116
296
  if (request.method === "POST" && url.pathname === "/api/resources") {
117
- const payload = await readJson(request);
118
- const record = payload.record ?? payload;
297
+ const payload = normalizeResourceMutation(await readJson(request));
298
+ const { record } = payload;
119
299
  const result = await browserMutation(input, options, {
120
300
  message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`
121
- }, () => createResource(input, record, { content: payload.record ? payload.content : undefined }));
122
- return json(response, 201, { record: result.record, synchronization: result.synchronization });
301
+ }, () => createResource(input, record, { content: payload.content }));
302
+ return json(response, 201, result);
123
303
  }
124
304
  if (request.method === "POST" && url.pathname === "/api/commit") {
125
305
  await requireManualBrowserGit(input, options);
126
306
  const payload = await readJson(request);
127
- return json(response, 201, await commitAndPushWorkspace(input, payload.message));
307
+ return json(response, 201, await manualGitResultWithState(input, options, () => commitAndPushWorkspace(input, payload.message)));
128
308
  }
129
309
  if (request.method === "POST" && url.pathname === "/api/git/pull") {
130
310
  await requireManualBrowserGit(input, options);
131
- return json(response, 200, await pullWorkspace(input));
311
+ return json(response, 200, await manualGitResultWithState(input, options, () => pullWorkspace(input)));
132
312
  }
133
313
  if (request.method === "POST" && url.pathname === "/api/git/push") {
134
314
  await requireManualBrowserGit(input, options);
135
- return json(response, 200, await pushWorkspace(input));
315
+ return json(response, 200, await manualGitResultWithState(input, options, () => pushWorkspace(input)));
136
316
  }
137
317
  if (request.method === "POST" && url.pathname === "/api/git/retry-sync") {
138
- return json(response, 200, await retryBrowserSync(input, {
318
+ const result = await retryBrowserSync(input, {
139
319
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
140
- }));
320
+ });
321
+ const state = await createAppState(input, {
322
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
323
+ includeDetails: false
324
+ });
325
+ return json(response, 200, { ...result, state });
326
+ }
327
+ if (request.method === "GET" && url.pathname === "/api/git/sync-status") {
328
+ const repository = await getBrowserRepositoryState(input, {
329
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
330
+ });
331
+ const git = getGitSummary(input);
332
+ delete git.root;
333
+ return json(response, 200, {
334
+ repository,
335
+ git,
336
+ readOnly: repository.mode === "trunk" && !repository.writesAllowed
337
+ });
141
338
  }
142
339
  if (request.method === "PUT" && url.pathname === "/api/content") {
143
340
  const payload = await readJson(request);
144
341
  const result = await browserMutation(input, options, {
145
342
  message: () => `Update content: ${payload.path}`
146
- }, () => updateContent(input, payload.path, payload.source, { expectedRevision: payload.revision }));
147
- return json(response, 200, { path: result.dataRelativePath, synchronization: result.synchronization });
343
+ }, () => updateContent(input, payload.path, payload.source, {
344
+ expectedRevision: requireRevision(payload.revision, `content/${payload.path}`)
345
+ }));
346
+ return json(response, 200, result);
148
347
  }
149
348
  const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
150
349
  if (match) {
@@ -152,32 +351,40 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
152
351
  const id = decodeURIComponent(match[2]);
153
352
  if (!safeSegment(type) || !safeSegment(id)) return json(response, 400, { error: "Unsafe resource identifier." });
154
353
  if (request.method === "GET") {
155
- const state = await createAppState(input);
156
- const entry = state.resources.find(({ record }) => record.type === type && record.id === id);
157
- return entry ? json(response, 200, entry) : json(response, 404, { error: "Resource not found." });
354
+ const entry = await createResourceDetail(input, type, id);
355
+ if (!entry) return json(response, 404, { error: "Resource not found." });
356
+ if (url.searchParams.get("workflow") === "true") {
357
+ const workflow = await assessWorkflow(input);
358
+ entry.workflow = workflowForResource(workflow, type, id);
359
+ }
360
+ return json(response, 200, entry);
158
361
  }
159
362
  if (request.method === "PUT") {
160
- const payload = await readJson(request);
161
- const record = payload.record ?? payload;
363
+ const payload = normalizeResourceMutation(await readJson(request), { requireRevision: true });
364
+ const { record } = payload;
162
365
  const result = await browserMutation(input, options, {
163
366
  message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}`
164
367
  }, () => updateResource(input, type, id, record, {
165
- content: payload.record ? payload.content : undefined,
368
+ content: payload.content,
166
369
  expectedRevision: payload.revision,
167
- expectedContentRevisions: payload.contentRevisions
370
+ expectedContentRevisions: payload.contentRevisions,
371
+ requireExpectedContentRevisions: true
168
372
  }));
169
- return json(response, 200, { record: result.record, synchronization: result.synchronization });
373
+ return json(response, 200, result);
170
374
  }
171
375
  if (request.method === "DELETE") {
376
+ const revision = requireRevision(url.searchParams.get("revision"), `${type}/${id}`);
172
377
  const result = await browserMutation(input, options, {
173
378
  message: () => `Delete ${resourceTypeLabel(type)}: ${id}`
174
- }, () => deleteResource(input, type, id, { expectedRevision: url.searchParams.get("revision") }));
379
+ }, () => deleteResource(input, type, id, { expectedRevision: revision }));
175
380
  return json(response, 200, {
176
381
  deleted: true,
177
382
  type,
178
383
  id,
179
384
  deletedContent: result.deletedContent,
180
- synchronization: result.synchronization
385
+ synchronization: result.synchronization,
386
+ workflowDelta: result.workflowDelta,
387
+ state: result.state
181
388
  });
182
389
  }
183
390
  }
@@ -232,25 +439,81 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
232
439
  getResourceDefinition(loaded.model, "workspace");
233
440
  const server = createFilegrcServer(loaded.root, {
234
441
  allowedHosts: [host],
235
- allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
236
- });
237
- await new Promise((resolve, reject) => {
238
- server.once("error", reject);
239
- server.listen(port, host, resolve);
442
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
443
+ backgroundPushDelayMs: options.backgroundPushDelayMs
240
444
  });
445
+ let usedFallbackPort = false;
446
+ try {
447
+ await listen(server, port, host);
448
+ } catch (error) {
449
+ if (
450
+ error.code !== "EADDRINUSE"
451
+ || port === 0
452
+ || options.fallbackToAvailablePort !== true
453
+ ) {
454
+ throw error;
455
+ }
456
+ await listen(server, 0, host);
457
+ usedFallbackPort = true;
458
+ }
241
459
  return {
242
460
  server,
243
461
  root: loaded.root,
244
462
  address: server.address(),
245
- url: `http://${urlHost(host)}:${server.address().port}`
463
+ url: `http://${urlHost(host)}:${server.address().port}`,
464
+ requestedPort: port,
465
+ usedFallbackPort
246
466
  };
247
467
  }
248
468
 
469
+ function listen(server, port, host) {
470
+ return new Promise((resolve, reject) => {
471
+ const onError = (error) => {
472
+ server.off("listening", onListening);
473
+ reject(error);
474
+ };
475
+ const onListening = () => {
476
+ server.off("error", onError);
477
+ resolve();
478
+ };
479
+ server.once("error", onError);
480
+ server.once("listening", onListening);
481
+ server.listen(port, host);
482
+ });
483
+ }
484
+
249
485
  function browserMutation(input, options, mutationOptions, task) {
250
- return runBrowserMutation(input, {
251
- ...mutationOptions,
252
- allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
253
- }, task);
486
+ const run = () => serializeWorkspaceMutation(input, async (root) => {
487
+ const workflowBefore = await assessWorkflow(root);
488
+ const result = await runBrowserMutation(root, {
489
+ ...mutationOptions,
490
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
491
+ backgroundPushDelayMs: options.backgroundPushDelayMs
492
+ }, task);
493
+ const state = await measureTiming("state", () => createAppState(root, {
494
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
495
+ includeDetails: false,
496
+ validationProof: result?.[BROWSER_VALIDATION]
497
+ }));
498
+ if (result?.synchronization?.status === "syncing" && state.repository.status !== "syncing") {
499
+ result.synchronization = {
500
+ ...result.synchronization,
501
+ status: state.repository.status === "synced" ? "synced" : "not-synced",
502
+ synchronizedAt: state.repository.lastSuccessfulSynchronization ?? null,
503
+ pushError: state.repository.backgroundSyncError ?? null
504
+ };
505
+ }
506
+ return {
507
+ ...result,
508
+ workflowDelta: buildWorkflowDelta(workflowBefore, state.workflow),
509
+ state
510
+ };
511
+ });
512
+ if (!timingEnabled()) return run();
513
+ return collectTimings(run).then(({ result, timings }) => {
514
+ console.error(`[filegrc timing] ${JSON.stringify({ operation: "browser-mutation", ...timings })}`);
515
+ return result;
516
+ });
254
517
  }
255
518
 
256
519
  async function requireManualBrowserGit(input, options) {
@@ -262,6 +525,15 @@ async function requireManualBrowserGit(input, options) {
262
525
  }
263
526
  }
264
527
 
528
+ async function manualGitResultWithState(input, options, task) {
529
+ const result = await task();
530
+ const state = await createAppState(input, {
531
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
532
+ includeDetails: false
533
+ });
534
+ return { ...result, state };
535
+ }
536
+
265
537
  function resourceTypeLabel(value) {
266
538
  return String(value || "record").replaceAll("-", " ");
267
539
  }
@@ -283,6 +555,18 @@ async function readJson(request) {
283
555
  return value;
284
556
  }
285
557
 
558
+ async function readBytes(request) {
559
+ const chunks = [];
560
+ let size = 0;
561
+ for await (const chunk of request) {
562
+ size += chunk.length;
563
+ if (size > 25_000_000) throw new Error("Attachment exceeds 25 MB.");
564
+ chunks.push(chunk);
565
+ }
566
+ if (size === 0) throw new Error("An attachment body is required.");
567
+ return Buffer.concat(chunks);
568
+ }
569
+
286
570
  function json(response, status, value) {
287
571
  text(response, status, `${JSON.stringify(value, null, 2)}\n`, "application/json; charset=utf-8");
288
572
  }
@@ -319,6 +603,20 @@ function safeSegment(value) {
319
603
  return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
320
604
  }
321
605
 
606
+ function safeAttachmentName(value) {
607
+ return typeof value === "string"
608
+ && value.length > 0
609
+ && value === value.split(/[\\/]/).at(-1)
610
+ && !value.startsWith(".");
611
+ }
612
+
613
+ function requireRevision(value, target) {
614
+ if (typeof value !== "string" || value.length === 0) {
615
+ throw new Error(`A revision is required when changing ${target}. Reload the resource and try again.`);
616
+ }
617
+ return value;
618
+ }
619
+
322
620
  function sameOrigin(request) {
323
621
  const origin = request.headers.origin;
324
622
  if (!origin) return true;
@@ -362,9 +660,10 @@ function urlHost(host) {
362
660
  function statusFor(error) {
363
661
  if (error instanceof SyntaxError || error instanceof URIError) return 400;
364
662
  if (/exceeds 2 MB/i.test(error.message)) return 413;
663
+ if (/exceeds 25 MB/i.test(error.message)) return 413;
365
664
  if (/changed after you opened|source changed|revision changed/i.test(error.message)) return 409;
366
665
  if (/already exists|target file already exists/i.test(error.message)) return 409;
367
- if (/Git could not|upstream branch|multiple remotes|no Git remote|configured repository remote|safe Git name|check out a branch|before trying to (?:pull|push)|authoritative branch|not synchronized|not synced|diverged|waiting to be pushed|Retry sync|outside this FileGRC workspace|worktree has uncommitted changes|development write override|browser commit, pull, and push/i.test(error.message)) return 409;
666
+ if (/Git could not|upstream branch|multiple remotes|no Git remote|configured repository remote|safe Git name|check out a branch|before trying to (?:pull|push)|authoritative branch|not synchronized|not synced|diverged|waiting to be pushed|Retry sync|background push|outside this FileGRC workspace|worktree has uncommitted changes|development write override|browser commit, pull, and push/i.test(error.message)) return 409;
368
667
  if (/not found|ENOENT/i.test(error.message)) return 404;
369
668
  if (/invalid|required|unsafe|match|workspace|singleton|commit message|no changes|git history|git user|unknown resource type|must use|must be|content path|data path|path leaves|valid .*date|not found|no active obligations|end date|through date|already exists|EEXIST/i.test(error.message)) return 400;
370
669
  return 500;