filegrc 0.4.0 → 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.
@@ -0,0 +1,277 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { createObligationEvent } from "./obligations.js";
6
+ import { markdownEntries } from "./resource-markdown.js";
7
+ import { loadWorkspace } from "./workspace.js";
8
+
9
+ const TRANSITIONS = {
10
+ person: [
11
+ {
12
+ eventType: "person-started",
13
+ applies: (before, after) => after?.status === "active" && before?.status !== "active",
14
+ message: "Confirm whether activating this Person represents a workforce start that needs policy-event work."
15
+ },
16
+ {
17
+ eventType: "person-ended",
18
+ applies: (before, after) => before?.status === "active" && after?.status !== "active",
19
+ message: "Confirm whether this Person status change represents a departure that needs access and asset work."
20
+ },
21
+ {
22
+ eventType: "person-role-changed",
23
+ applies: (before, after) => before && after && before.jobTitle !== after.jobTitle,
24
+ message: "Confirm whether this job-title change represents a role change that needs access and training work."
25
+ }
26
+ ],
27
+ vendor: [
28
+ {
29
+ eventType: "vendor-activated",
30
+ applies: (before, after) => after?.status === "active" && before?.status !== "active",
31
+ message: "Confirm whether activating this Vendor needs onboarding, assurance, and access work."
32
+ },
33
+ {
34
+ eventType: "vendor-terminated",
35
+ applies: (before, after) => before?.status === "active" && ["inactive", "terminated"].includes(after?.status),
36
+ message: "Confirm whether this Vendor status change needs termination, access, and data-return work."
37
+ }
38
+ ],
39
+ system: [{
40
+ eventType: "system-material-change",
41
+ applies: (before, after) => before && after && materialFieldsChanged(before, after, [
42
+ "boundary", "criticality", "classificationId", "internetExposed", "vendorIds", "ownerIds"
43
+ ]),
44
+ message: "Confirm whether this System change is material and needs the configured change workflow."
45
+ }],
46
+ incident: [{
47
+ eventType: "incident-closed",
48
+ applies: (before, after) => before && after && before.status !== "closed" && after.status === "closed",
49
+ message: "Confirm whether closing this Incident needs lessons-learned, disclosure, or remediation work."
50
+ }],
51
+ policy: [{
52
+ eventType: "policy-revised",
53
+ applies: (before, after, context) => Boolean(
54
+ before
55
+ && after
56
+ && ["approved", "active", "superseded", "retired"].includes(before.status)
57
+ && (
58
+ context.markdownChanged
59
+ || materialFieldsChanged(before, after, ["status", "effectiveOn", "ownerIds", "approverIds"])
60
+ )
61
+ ),
62
+ message: "Confirm whether this Policy revision is material and needs reapproval, training, or acknowledgement work."
63
+ }],
64
+ exception: [{
65
+ eventType: "exception-expired",
66
+ applies: (before, after) => before && after && before.status !== "expired" && after.status === "expired",
67
+ message: "Confirm whether this Exception expiry needs compensating-control review or remediation work."
68
+ }],
69
+ "service-account": [
70
+ {
71
+ eventType: "service-account-created",
72
+ applies: (before, after) => after?.status === "active" && before?.status !== "active",
73
+ message: "Confirm whether activating this Service Account needs authorization and review work."
74
+ },
75
+ {
76
+ eventType: "service-account-expired",
77
+ applies: (before, after) => before?.status === "active" && ["expired", "disabled"].includes(after?.status),
78
+ message: "Confirm whether this Service Account status change needs disablement and access-review work."
79
+ }
80
+ ],
81
+ vulnerability: [{
82
+ eventType: "vulnerability-confirmed",
83
+ applies: (before, after) => after?.confirmedOn && before?.confirmedOn !== after.confirmedOn,
84
+ message: "Confirm whether this newly confirmed Vulnerability needs remediation and tracking work."
85
+ }],
86
+ asset: [{
87
+ eventType: "asset-disposed",
88
+ applies: (before, after) => before && after && before.status !== "disposed" && after.status === "disposed",
89
+ message: "Confirm whether this Asset disposal needs sanitization and evidence work."
90
+ }]
91
+ };
92
+
93
+ export async function planReconciliation(input = process.cwd()) {
94
+ const loaded = await loadWorkspace(input);
95
+ if (String(loaded.model.modelVersion) !== "3") {
96
+ return {
97
+ contractVersion: 1,
98
+ gitRevision: gitRevision(loaded.root),
99
+ changedPaths: [],
100
+ candidates: [],
101
+ message: "Direct-file transition reconciliation is available after migrating this workspace to model v3."
102
+ };
103
+ }
104
+ const changedPaths = gitChangedPaths(loaded.root);
105
+ const currentByPath = new Map(loaded.entries.map((entry) => [
106
+ `data/${entry.relativePath}`,
107
+ entry
108
+ ]));
109
+ const markdownOwners = new Map();
110
+ for (const entry of loaded.entries) {
111
+ for (const markdown of markdownEntries(loaded.model, entry.record)) {
112
+ markdownOwners.set(`data/${markdown.path}`, entry);
113
+ }
114
+ }
115
+ const candidates = [];
116
+ const examined = new Set();
117
+
118
+ for (const path of changedPaths) {
119
+ const currentEntry = currentByPath.get(path) || markdownOwners.get(path);
120
+ const previous = readHeadRecord(loaded.root, path.endsWith(".json")
121
+ ? path
122
+ : currentEntry ? `data/${currentEntry.relativePath}` : null);
123
+ const current = currentEntry?.record || null;
124
+ const record = current || previous;
125
+ if (!record || examined.has(`${record.type}:${record.id}`)) continue;
126
+ examined.add(`${record.type}:${record.id}`);
127
+ const changedMarkdownPaths = changedPaths.filter((changed) => (
128
+ markdownOwners.get(changed)?.record.id === record.id
129
+ ));
130
+ const markdownChanged = changedMarkdownPaths.length > 0;
131
+ const currentMarkdown = await Promise.all(changedMarkdownPaths.map(async (changed) => {
132
+ try {
133
+ return await readFile(join(loaded.root, changed), "utf8");
134
+ } catch {
135
+ return "";
136
+ }
137
+ }));
138
+ const previousMarkdown = changedMarkdownPaths.map((changed) => readHeadSource(loaded.root, changed));
139
+ const currentSource = [currentEntry?.source || "", ...currentMarkdown].join("\n");
140
+ const beforeSource = [previous ? JSON.stringify(previous) : "", ...previousMarkdown].join("\n");
141
+ for (const transition of TRANSITIONS[record.type] || []) {
142
+ if (!transition.applies(previous, current, { markdownChanged })) continue;
143
+ const fingerprint = transitionFingerprint({
144
+ eventType: transition.eventType,
145
+ subjectId: record.id,
146
+ path: `data/${currentEntry?.relativePath || path.replace(/^data\//, "")}`,
147
+ beforeSource,
148
+ currentSource,
149
+ markdownChanged
150
+ });
151
+ if (loaded.resources.some((item) => (
152
+ item.type === "obligation-event"
153
+ && item.transitionFingerprint === fingerprint
154
+ ))) continue;
155
+ candidates.push({
156
+ id: `reconcile-${fingerprint.slice(0, 16)}`,
157
+ transitionFingerprint: fingerprint,
158
+ eventType: transition.eventType,
159
+ subject: { type: record.type, id: record.id, title: record.title },
160
+ sourcePath: path,
161
+ state: "needs-confirmation",
162
+ message: transition.message,
163
+ requiredFacts: [
164
+ transition.eventType === "person-ended" ? "riskLevel" : null,
165
+ eventNeedsTimestamp(loaded, transition.eventType) ? "occurredAt" : "occurredOn"
166
+ ].filter(Boolean),
167
+ action: {
168
+ kind: "command",
169
+ command: reconciliationCommand(transition.eventType, record.id, fingerprint)
170
+ }
171
+ });
172
+ }
173
+ }
174
+ return {
175
+ contractVersion: 1,
176
+ gitRevision: gitRevision(loaded.root),
177
+ changedPaths,
178
+ candidates: candidates.sort((a, b) => a.id.localeCompare(b.id))
179
+ };
180
+ }
181
+
182
+ export async function applyReconciliation(input = process.cwd(), options = {}) {
183
+ if (options.confirmed !== true) {
184
+ throw new Error("Reconciliation creates compliance records. Preview the candidate and confirm the write.");
185
+ }
186
+ const plan = await planReconciliation(input);
187
+ const candidate = plan.candidates.find(({ id, transitionFingerprint }) => (
188
+ id === options.candidateId || transitionFingerprint === options.transitionFingerprint
189
+ ));
190
+ if (!candidate) {
191
+ throw new Error("The reconciliation candidate is missing or changed. Run reconcile --preview again.");
192
+ }
193
+ const result = await createObligationEvent(input, {
194
+ eventType: candidate.eventType,
195
+ subjectResourceIds: [candidate.subject.id],
196
+ occurredOn: options.occurredOn,
197
+ occurredAt: options.occurredAt,
198
+ riskLevel: options.riskLevel,
199
+ title: options.title,
200
+ transitionFingerprint: candidate.transitionFingerprint
201
+ });
202
+ return { candidate, ...result };
203
+ }
204
+
205
+ function gitChangedPaths(root) {
206
+ const tracked = runGit(root, ["diff", "--name-only", "HEAD", "--", "data"]);
207
+ const untracked = runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]);
208
+ return [...new Set([...lines(tracked), ...lines(untracked)])]
209
+ .filter((path) => path.startsWith("data/"))
210
+ .sort();
211
+ }
212
+
213
+ function readHeadRecord(root, path) {
214
+ if (!path) return null;
215
+ try {
216
+ return JSON.parse(readHeadSource(root, path));
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ function readHeadSource(root, path) {
223
+ try {
224
+ return execFileSync("git", ["show", `HEAD:${path}`], {
225
+ cwd: root,
226
+ encoding: "utf8",
227
+ stdio: ["ignore", "pipe", "ignore"],
228
+ timeout: 10_000
229
+ });
230
+ } catch {
231
+ return "";
232
+ }
233
+ }
234
+
235
+ function gitRevision(root) {
236
+ return runGit(root, ["rev-parse", "HEAD"]) || null;
237
+ }
238
+
239
+ function runGit(root, args) {
240
+ try {
241
+ return execFileSync("git", args, {
242
+ cwd: root,
243
+ encoding: "utf8",
244
+ stdio: ["ignore", "pipe", "ignore"],
245
+ timeout: 10_000
246
+ }).trim();
247
+ } catch {
248
+ return "";
249
+ }
250
+ }
251
+
252
+ function lines(value) {
253
+ return value ? value.split("\n").map((line) => line.trim()).filter(Boolean) : [];
254
+ }
255
+
256
+ function materialFieldsChanged(before, after, fields) {
257
+ return fields.some((field) => JSON.stringify(before?.[field]) !== JSON.stringify(after?.[field]));
258
+ }
259
+
260
+ function transitionFingerprint(value) {
261
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
262
+ }
263
+
264
+ function eventNeedsTimestamp(loaded, eventType) {
265
+ return loaded.resources.some((record) => (
266
+ record.type === "obligation"
267
+ && record.status === "active"
268
+ && record.recurrence?.eventType === eventType
269
+ && record.window?.precision === "timestamp"
270
+ ));
271
+ }
272
+
273
+ function reconciliationCommand(eventType, subjectId, fingerprint) {
274
+ const timeFlag = " --occurred-on YYYY-MM-DD";
275
+ const riskFlag = eventType === "person-ended" ? " --risk-level normal|high" : "";
276
+ return `npx filegrc reconcile --apply --candidate ${fingerprint}${timeFlag}${riskFlag} --yes`;
277
+ }
package/src/server.js CHANGED
@@ -1,11 +1,22 @@
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
11
  import { FAVICON_PNG, LOGO_MARK_PNG } from "./favicon.js";
8
- import { createResource, deleteResource, updateContent, updateResource } from "./files.js";
12
+ import {
13
+ addEvidenceAttachment,
14
+ createResource,
15
+ deleteResource,
16
+ removeEvidenceAttachment,
17
+ updateContent,
18
+ updateResource
19
+ } from "./files.js";
9
20
  import {
10
21
  BROWSER_VALIDATION,
11
22
  commitAndPushWorkspace,
@@ -18,11 +29,28 @@ import {
18
29
  runBrowserMutation
19
30
  } from "./git.js";
20
31
  import { normalizeResourceMutation, serializeWorkspaceMutation } from "./mutation.js";
21
- import { completeObligationOccurrence, createObligationEvent, planObligations } from "./obligations.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";
22
43
  import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
44
+ import { applyReconciliation, planReconciliation } from "./reconciliation.js";
23
45
  import { createAppState, createResourceDetail } from "./state.js";
24
46
  import { setupWorkspace } from "./setup.js";
25
47
  import { collectTimings, measureTiming, timingEnabled } from "./timing.js";
48
+ import {
49
+ assessWorkflow,
50
+ buildWorkflowDelta,
51
+ previewWorkflowMutation,
52
+ workflowForResource
53
+ } from "./workflow.js";
26
54
  import { loadWorkspace } from "./workspace.js";
27
55
  import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
28
56
 
@@ -66,6 +94,80 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
66
94
  model: loaded.model
67
95
  }));
68
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,
105
+ includeComplete: url.searchParams.get("includeComplete") === "true"
106
+ }));
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
+ }
69
171
  if (request.method === "POST" && url.pathname === "/api/obligation-events") {
70
172
  const payload = await readJson(request);
71
173
  return json(response, 201, await browserMutation(input, options, {
@@ -85,6 +187,73 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
85
187
  }));
86
188
  return json(response, 201, result);
87
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
+ }
88
257
  if (request.method === "GET" && url.pathname === "/api/evidence-packet") {
89
258
  return json(response, 200, await prepareEvidencePacket(input, {
90
259
  start: url.searchParams.get("start"),
@@ -130,7 +299,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
130
299
  const result = await browserMutation(input, options, {
131
300
  message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`
132
301
  }, () => createResource(input, record, { content: payload.content }));
133
- return json(response, 201, { record: result.record, synchronization: result.synchronization, state: result.state });
302
+ return json(response, 201, result);
134
303
  }
135
304
  if (request.method === "POST" && url.pathname === "/api/commit") {
136
305
  await requireManualBrowserGit(input, options);
@@ -174,7 +343,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
174
343
  }, () => updateContent(input, payload.path, payload.source, {
175
344
  expectedRevision: requireRevision(payload.revision, `content/${payload.path}`)
176
345
  }));
177
- return json(response, 200, { path: result.dataRelativePath, synchronization: result.synchronization, state: result.state });
346
+ return json(response, 200, result);
178
347
  }
179
348
  const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
180
349
  if (match) {
@@ -183,7 +352,12 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
183
352
  if (!safeSegment(type) || !safeSegment(id)) return json(response, 400, { error: "Unsafe resource identifier." });
184
353
  if (request.method === "GET") {
185
354
  const entry = await createResourceDetail(input, type, id);
186
- return entry ? json(response, 200, entry) : json(response, 404, { error: "Resource not found." });
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);
187
361
  }
188
362
  if (request.method === "PUT") {
189
363
  const payload = normalizeResourceMutation(await readJson(request), { requireRevision: true });
@@ -196,7 +370,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
196
370
  expectedContentRevisions: payload.contentRevisions,
197
371
  requireExpectedContentRevisions: true
198
372
  }));
199
- return json(response, 200, { record: result.record, synchronization: result.synchronization, state: result.state });
373
+ return json(response, 200, result);
200
374
  }
201
375
  if (request.method === "DELETE") {
202
376
  const revision = requireRevision(url.searchParams.get("revision"), `${type}/${id}`);
@@ -209,6 +383,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
209
383
  id,
210
384
  deletedContent: result.deletedContent,
211
385
  synchronization: result.synchronization,
386
+ workflowDelta: result.workflowDelta,
212
387
  state: result.state
213
388
  });
214
389
  }
@@ -267,20 +442,49 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
267
442
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
268
443
  backgroundPushDelayMs: options.backgroundPushDelayMs
269
444
  });
270
- await new Promise((resolve, reject) => {
271
- server.once("error", reject);
272
- server.listen(port, host, resolve);
273
- });
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
+ }
274
459
  return {
275
460
  server,
276
461
  root: loaded.root,
277
462
  address: server.address(),
278
- url: `http://${urlHost(host)}:${server.address().port}`
463
+ url: `http://${urlHost(host)}:${server.address().port}`,
464
+ requestedPort: port,
465
+ usedFallbackPort
279
466
  };
280
467
  }
281
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
+
282
485
  function browserMutation(input, options, mutationOptions, task) {
283
486
  const run = () => serializeWorkspaceMutation(input, async (root) => {
487
+ const workflowBefore = await assessWorkflow(root);
284
488
  const result = await runBrowserMutation(root, {
285
489
  ...mutationOptions,
286
490
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
@@ -299,7 +503,11 @@ function browserMutation(input, options, mutationOptions, task) {
299
503
  pushError: state.repository.backgroundSyncError ?? null
300
504
  };
301
505
  }
302
- return { ...result, state };
506
+ return {
507
+ ...result,
508
+ workflowDelta: buildWorkflowDelta(workflowBefore, state.workflow),
509
+ state
510
+ };
303
511
  });
304
512
  if (!timingEnabled()) return run();
305
513
  return collectTimings(run).then(({ result, timings }) => {
@@ -347,6 +555,18 @@ async function readJson(request) {
347
555
  return value;
348
556
  }
349
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
+
350
570
  function json(response, status, value) {
351
571
  text(response, status, `${JSON.stringify(value, null, 2)}\n`, "application/json; charset=utf-8");
352
572
  }
@@ -383,6 +603,13 @@ function safeSegment(value) {
383
603
  return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
384
604
  }
385
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
+
386
613
  function requireRevision(value, target) {
387
614
  if (typeof value !== "string" || value.length === 0) {
388
615
  throw new Error(`A revision is required when changing ${target}. Reload the resource and try again.`);
@@ -433,6 +660,7 @@ function urlHost(host) {
433
660
  function statusFor(error) {
434
661
  if (error instanceof SyntaxError || error instanceof URIError) return 400;
435
662
  if (/exceeds 2 MB/i.test(error.message)) return 413;
663
+ if (/exceeds 25 MB/i.test(error.message)) return 413;
436
664
  if (/changed after you opened|source changed|revision changed/i.test(error.message)) return 409;
437
665
  if (/already exists|target file already exists/i.test(error.message)) return 409;
438
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;