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/README.md +24 -6
- package/model/index.js +41 -3
- package/model/v1.json +81 -47
- package/model/v2.json +8022 -0
- package/model/v3.json +9391 -0
- package/package.json +2 -2
- package/src/agent.js +89 -8
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +109 -65
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +563 -148
- package/src/collection-review.js +185 -0
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +149 -77
- package/src/external-reviewer.js +165 -0
- package/src/files.js +267 -29
- package/src/git.js +239 -41
- package/src/index.js +41 -7
- package/src/model-docs.js +103 -7
- package/src/model-migration.js +1958 -0
- package/src/mutation.js +42 -0
- package/src/obligations.js +502 -95
- package/src/parties.js +17 -2
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +70 -60
- package/src/program-readiness.js +470 -130
- package/src/reconciliation.js +277 -0
- package/src/resource-status.js +17 -0
- package/src/server.js +347 -48
- package/src/setup.js +57 -26
- package/src/source-coverage.js +61 -0
- package/src/state.js +122 -25
- package/src/timing.js +41 -0
- package/src/validate.js +707 -44
- package/src/web.js +1440 -304
- package/src/workflow.js +1595 -0
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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.
|
|
122
|
-
return json(response, 201,
|
|
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
|
-
|
|
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, {
|
|
147
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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.
|
|
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,
|
|
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:
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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;
|