filegrc 0.3.3 → 0.4.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 +17 -7
- package/model/index.js +37 -3
- package/model/v1.json +89 -52
- package/model/v2.json +8022 -0
- package/package.json +1 -1
- package/src/agent.js +36 -8
- package/src/audit-preparation.js +63 -60
- package/src/cli.js +176 -113
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +115 -75
- package/src/files.js +230 -28
- package/src/git-name.js +16 -0
- package/src/git.js +702 -6
- package/src/index.js +9 -6
- package/src/model-docs.js +88 -7
- package/src/model-migration.js +1463 -0
- package/src/mutation.js +46 -1
- package/src/obligations.js +108 -84
- package/src/parties.js +17 -2
- package/src/program-path.js +31 -58
- package/src/program-readiness.js +142 -106
- package/src/resource-status.js +17 -0
- package/src/server.js +175 -36
- package/src/setup.js +27 -28
- package/src/state.js +93 -27
- package/src/timing.js +41 -0
- package/src/validate.js +611 -43
- package/src/web.js +586 -141
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
package/src/server.js
CHANGED
|
@@ -4,14 +4,25 @@ import { extname, resolve } from "node:path";
|
|
|
4
4
|
import { getResourceDefinition } from "../model/index.js";
|
|
5
5
|
import { prepareAuditWorkspace } from "./audit-preparation.js";
|
|
6
6
|
import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet.js";
|
|
7
|
-
import { ensureEvidenceTestDrafts } from "./evidence-tests.js";
|
|
8
7
|
import { FAVICON_PNG, LOGO_MARK_PNG } from "./favicon.js";
|
|
9
8
|
import { createResource, deleteResource, updateContent, updateResource } from "./files.js";
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
BROWSER_VALIDATION,
|
|
11
|
+
commitAndPushWorkspace,
|
|
12
|
+
getBrowserRepositoryState,
|
|
13
|
+
getFileHistory,
|
|
14
|
+
getGitSummary,
|
|
15
|
+
pullWorkspace,
|
|
16
|
+
pushWorkspace,
|
|
17
|
+
retryBrowserSync,
|
|
18
|
+
runBrowserMutation
|
|
19
|
+
} from "./git.js";
|
|
20
|
+
import { normalizeResourceMutation, serializeWorkspaceMutation } from "./mutation.js";
|
|
11
21
|
import { completeObligationOccurrence, createObligationEvent, planObligations } from "./obligations.js";
|
|
12
22
|
import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
|
|
13
|
-
import { createAppState } from "./state.js";
|
|
23
|
+
import { createAppState, createResourceDetail } from "./state.js";
|
|
14
24
|
import { setupWorkspace } from "./setup.js";
|
|
25
|
+
import { collectTimings, measureTiming, timingEnabled } from "./timing.js";
|
|
15
26
|
import { loadWorkspace } from "./workspace.js";
|
|
16
27
|
import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
|
|
17
28
|
|
|
@@ -26,7 +37,18 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
26
37
|
return json(response, 403, { error: "Cross-origin writes are not allowed." });
|
|
27
38
|
}
|
|
28
39
|
if (request.method === "GET" && url.pathname === "/api/state") {
|
|
29
|
-
|
|
40
|
+
if (timingEnabled()) {
|
|
41
|
+
const { result, timings } = await collectTimings(() => measureTiming("state", () => createAppState(input, {
|
|
42
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
43
|
+
includeDetails: false
|
|
44
|
+
})));
|
|
45
|
+
console.error(`[filegrc timing] ${JSON.stringify({ operation: "state", ...timings })}`);
|
|
46
|
+
return json(response, 200, result);
|
|
47
|
+
}
|
|
48
|
+
return json(response, 200, await createAppState(input, {
|
|
49
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
50
|
+
includeDetails: false
|
|
51
|
+
}));
|
|
30
52
|
}
|
|
31
53
|
if (request.method === "GET" && url.pathname === "/api/history") {
|
|
32
54
|
const path = url.searchParams.get("path");
|
|
@@ -40,21 +62,27 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
40
62
|
from: url.searchParams.get("from") || undefined,
|
|
41
63
|
through: url.searchParams.get("through") || undefined,
|
|
42
64
|
now: url.searchParams.get("now") || undefined,
|
|
43
|
-
includeComplete: url.searchParams.get("includeComplete") === "true"
|
|
65
|
+
includeComplete: url.searchParams.get("includeComplete") === "true",
|
|
66
|
+
model: loaded.model
|
|
44
67
|
}));
|
|
45
68
|
}
|
|
46
69
|
if (request.method === "POST" && url.pathname === "/api/obligation-events") {
|
|
47
|
-
|
|
70
|
+
const payload = await readJson(request);
|
|
71
|
+
return json(response, 201, await browserMutation(input, options, {
|
|
72
|
+
message: (result) => `Create policy event: ${result.event?.title || payload.title || payload.eventType}`
|
|
73
|
+
}, () => createObligationEvent(input, payload)));
|
|
48
74
|
}
|
|
49
75
|
if (request.method === "POST" && url.pathname === "/api/obligation-completions") {
|
|
50
76
|
const payload = await readJson(request);
|
|
51
77
|
if (!safeSegment(payload.obligationId)) return json(response, 400, { error: "A safe obligation ID is required." });
|
|
52
|
-
const result = await
|
|
78
|
+
const result = await browserMutation(input, options, {
|
|
79
|
+
message: () => `Complete ${resourceTypeLabel(payload.record?.type)}: ${payload.record?.title || payload.obligationId}`
|
|
80
|
+
}, () => completeObligationOccurrence(input, {
|
|
53
81
|
obligationId: payload.obligationId,
|
|
54
82
|
record: payload.record,
|
|
55
83
|
content: payload.content,
|
|
56
|
-
expectedRevision: payload.revision
|
|
57
|
-
});
|
|
84
|
+
expectedRevision: requireRevision(payload.revision, `obligation/${payload.obligationId}`)
|
|
85
|
+
}));
|
|
58
86
|
return json(response, 201, result);
|
|
59
87
|
}
|
|
60
88
|
if (request.method === "GET" && url.pathname === "/api/evidence-packet") {
|
|
@@ -82,34 +110,71 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
82
110
|
});
|
|
83
111
|
}
|
|
84
112
|
if (request.method === "POST" && url.pathname === "/api/audit-preparation") {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
113
|
+
const payload = await readJson(request);
|
|
114
|
+
return json(response, 201, await browserMutation(input, options, {
|
|
115
|
+
message: () => `Prepare audit: ${payload.auditId || "engagement"}`
|
|
116
|
+
}, () => prepareAuditWorkspace(input, payload)));
|
|
89
117
|
}
|
|
90
118
|
if (request.method === "POST" && url.pathname === "/api/setup") {
|
|
91
|
-
|
|
119
|
+
const payload = await readJson(request);
|
|
120
|
+
const completeSetup = async () => {
|
|
121
|
+
return browserMutation(input, options, {
|
|
122
|
+
message: (setupResult) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${setupResult.workspace.organizationName}`
|
|
123
|
+
}, () => setupWorkspace(input, payload));
|
|
124
|
+
};
|
|
125
|
+
return json(response, 200, await completeSetup());
|
|
92
126
|
}
|
|
93
127
|
if (request.method === "POST" && url.pathname === "/api/resources") {
|
|
94
|
-
const payload = await readJson(request);
|
|
95
|
-
const record = payload
|
|
96
|
-
const result = await
|
|
97
|
-
|
|
128
|
+
const payload = normalizeResourceMutation(await readJson(request));
|
|
129
|
+
const { record } = payload;
|
|
130
|
+
const result = await browserMutation(input, options, {
|
|
131
|
+
message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`
|
|
132
|
+
}, () => createResource(input, record, { content: payload.content }));
|
|
133
|
+
return json(response, 201, { record: result.record, synchronization: result.synchronization, state: result.state });
|
|
98
134
|
}
|
|
99
135
|
if (request.method === "POST" && url.pathname === "/api/commit") {
|
|
136
|
+
await requireManualBrowserGit(input, options);
|
|
100
137
|
const payload = await readJson(request);
|
|
101
|
-
return json(response, 201, await commitAndPushWorkspace(input, payload.message));
|
|
138
|
+
return json(response, 201, await manualGitResultWithState(input, options, () => commitAndPushWorkspace(input, payload.message)));
|
|
102
139
|
}
|
|
103
140
|
if (request.method === "POST" && url.pathname === "/api/git/pull") {
|
|
104
|
-
|
|
141
|
+
await requireManualBrowserGit(input, options);
|
|
142
|
+
return json(response, 200, await manualGitResultWithState(input, options, () => pullWorkspace(input)));
|
|
105
143
|
}
|
|
106
144
|
if (request.method === "POST" && url.pathname === "/api/git/push") {
|
|
107
|
-
|
|
145
|
+
await requireManualBrowserGit(input, options);
|
|
146
|
+
return json(response, 200, await manualGitResultWithState(input, options, () => pushWorkspace(input)));
|
|
147
|
+
}
|
|
148
|
+
if (request.method === "POST" && url.pathname === "/api/git/retry-sync") {
|
|
149
|
+
const result = await retryBrowserSync(input, {
|
|
150
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
151
|
+
});
|
|
152
|
+
const state = await createAppState(input, {
|
|
153
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
154
|
+
includeDetails: false
|
|
155
|
+
});
|
|
156
|
+
return json(response, 200, { ...result, state });
|
|
157
|
+
}
|
|
158
|
+
if (request.method === "GET" && url.pathname === "/api/git/sync-status") {
|
|
159
|
+
const repository = await getBrowserRepositoryState(input, {
|
|
160
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
161
|
+
});
|
|
162
|
+
const git = getGitSummary(input);
|
|
163
|
+
delete git.root;
|
|
164
|
+
return json(response, 200, {
|
|
165
|
+
repository,
|
|
166
|
+
git,
|
|
167
|
+
readOnly: repository.mode === "trunk" && !repository.writesAllowed
|
|
168
|
+
});
|
|
108
169
|
}
|
|
109
170
|
if (request.method === "PUT" && url.pathname === "/api/content") {
|
|
110
171
|
const payload = await readJson(request);
|
|
111
|
-
const result = await
|
|
112
|
-
|
|
172
|
+
const result = await browserMutation(input, options, {
|
|
173
|
+
message: () => `Update content: ${payload.path}`
|
|
174
|
+
}, () => updateContent(input, payload.path, payload.source, {
|
|
175
|
+
expectedRevision: requireRevision(payload.revision, `content/${payload.path}`)
|
|
176
|
+
}));
|
|
177
|
+
return json(response, 200, { path: result.dataRelativePath, synchronization: result.synchronization, state: result.state });
|
|
113
178
|
}
|
|
114
179
|
const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
|
|
115
180
|
if (match) {
|
|
@@ -117,23 +182,35 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
117
182
|
const id = decodeURIComponent(match[2]);
|
|
118
183
|
if (!safeSegment(type) || !safeSegment(id)) return json(response, 400, { error: "Unsafe resource identifier." });
|
|
119
184
|
if (request.method === "GET") {
|
|
120
|
-
const
|
|
121
|
-
const entry = state.resources.find(({ record }) => record.type === type && record.id === id);
|
|
185
|
+
const entry = await createResourceDetail(input, type, id);
|
|
122
186
|
return entry ? json(response, 200, entry) : json(response, 404, { error: "Resource not found." });
|
|
123
187
|
}
|
|
124
188
|
if (request.method === "PUT") {
|
|
125
|
-
const payload = await readJson(request);
|
|
126
|
-
const record = payload
|
|
127
|
-
const result = await
|
|
128
|
-
|
|
189
|
+
const payload = normalizeResourceMutation(await readJson(request), { requireRevision: true });
|
|
190
|
+
const { record } = payload;
|
|
191
|
+
const result = await browserMutation(input, options, {
|
|
192
|
+
message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}`
|
|
193
|
+
}, () => updateResource(input, type, id, record, {
|
|
194
|
+
content: payload.content,
|
|
129
195
|
expectedRevision: payload.revision,
|
|
130
|
-
expectedContentRevisions: payload.contentRevisions
|
|
131
|
-
|
|
132
|
-
|
|
196
|
+
expectedContentRevisions: payload.contentRevisions,
|
|
197
|
+
requireExpectedContentRevisions: true
|
|
198
|
+
}));
|
|
199
|
+
return json(response, 200, { record: result.record, synchronization: result.synchronization, state: result.state });
|
|
133
200
|
}
|
|
134
201
|
if (request.method === "DELETE") {
|
|
135
|
-
const
|
|
136
|
-
|
|
202
|
+
const revision = requireRevision(url.searchParams.get("revision"), `${type}/${id}`);
|
|
203
|
+
const result = await browserMutation(input, options, {
|
|
204
|
+
message: () => `Delete ${resourceTypeLabel(type)}: ${id}`
|
|
205
|
+
}, () => deleteResource(input, type, id, { expectedRevision: revision }));
|
|
206
|
+
return json(response, 200, {
|
|
207
|
+
deleted: true,
|
|
208
|
+
type,
|
|
209
|
+
id,
|
|
210
|
+
deletedContent: result.deletedContent,
|
|
211
|
+
synchronization: result.synchronization,
|
|
212
|
+
state: result.state
|
|
213
|
+
});
|
|
137
214
|
}
|
|
138
215
|
}
|
|
139
216
|
if (request.method === "GET" && url.pathname === "/favicon.png") return text(response, 200, FAVICON_PNG, "image/png");
|
|
@@ -185,7 +262,11 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
185
262
|
}
|
|
186
263
|
const loaded = await loadWorkspace(input);
|
|
187
264
|
getResourceDefinition(loaded.model, "workspace");
|
|
188
|
-
const server = createFilegrcServer(loaded.root, {
|
|
265
|
+
const server = createFilegrcServer(loaded.root, {
|
|
266
|
+
allowedHosts: [host],
|
|
267
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
|
|
268
|
+
backgroundPushDelayMs: options.backgroundPushDelayMs
|
|
269
|
+
});
|
|
189
270
|
await new Promise((resolve, reject) => {
|
|
190
271
|
server.once("error", reject);
|
|
191
272
|
server.listen(port, host, resolve);
|
|
@@ -198,6 +279,57 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
198
279
|
};
|
|
199
280
|
}
|
|
200
281
|
|
|
282
|
+
function browserMutation(input, options, mutationOptions, task) {
|
|
283
|
+
const run = () => serializeWorkspaceMutation(input, async (root) => {
|
|
284
|
+
const result = await runBrowserMutation(root, {
|
|
285
|
+
...mutationOptions,
|
|
286
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
|
|
287
|
+
backgroundPushDelayMs: options.backgroundPushDelayMs
|
|
288
|
+
}, task);
|
|
289
|
+
const state = await measureTiming("state", () => createAppState(root, {
|
|
290
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
291
|
+
includeDetails: false,
|
|
292
|
+
validationProof: result?.[BROWSER_VALIDATION]
|
|
293
|
+
}));
|
|
294
|
+
if (result?.synchronization?.status === "syncing" && state.repository.status !== "syncing") {
|
|
295
|
+
result.synchronization = {
|
|
296
|
+
...result.synchronization,
|
|
297
|
+
status: state.repository.status === "synced" ? "synced" : "not-synced",
|
|
298
|
+
synchronizedAt: state.repository.lastSuccessfulSynchronization ?? null,
|
|
299
|
+
pushError: state.repository.backgroundSyncError ?? null
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return { ...result, state };
|
|
303
|
+
});
|
|
304
|
+
if (!timingEnabled()) return run();
|
|
305
|
+
return collectTimings(run).then(({ result, timings }) => {
|
|
306
|
+
console.error(`[filegrc timing] ${JSON.stringify({ operation: "browser-mutation", ...timings })}`);
|
|
307
|
+
return result;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function requireManualBrowserGit(input, options) {
|
|
312
|
+
const repository = await getBrowserRepositoryState(input, {
|
|
313
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
314
|
+
});
|
|
315
|
+
if (repository.mode === "trunk") {
|
|
316
|
+
throw new Error("Browser commit, pull, and push controls are disabled in trunk mode. Saved changes synchronize automatically.");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function manualGitResultWithState(input, options, task) {
|
|
321
|
+
const result = await task();
|
|
322
|
+
const state = await createAppState(input, {
|
|
323
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
324
|
+
includeDetails: false
|
|
325
|
+
});
|
|
326
|
+
return { ...result, state };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function resourceTypeLabel(value) {
|
|
330
|
+
return String(value || "record").replaceAll("-", " ");
|
|
331
|
+
}
|
|
332
|
+
|
|
201
333
|
async function readJson(request) {
|
|
202
334
|
const chunks = [];
|
|
203
335
|
let size = 0;
|
|
@@ -251,6 +383,13 @@ function safeSegment(value) {
|
|
|
251
383
|
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
252
384
|
}
|
|
253
385
|
|
|
386
|
+
function requireRevision(value, target) {
|
|
387
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
388
|
+
throw new Error(`A revision is required when changing ${target}. Reload the resource and try again.`);
|
|
389
|
+
}
|
|
390
|
+
return value;
|
|
391
|
+
}
|
|
392
|
+
|
|
254
393
|
function sameOrigin(request) {
|
|
255
394
|
const origin = request.headers.origin;
|
|
256
395
|
if (!origin) return true;
|
|
@@ -296,7 +435,7 @@ function statusFor(error) {
|
|
|
296
435
|
if (/exceeds 2 MB/i.test(error.message)) return 413;
|
|
297
436
|
if (/changed after you opened|source changed|revision changed/i.test(error.message)) return 409;
|
|
298
437
|
if (/already exists|target file already exists/i.test(error.message)) return 409;
|
|
299
|
-
if (/Git could not
|
|
438
|
+
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;
|
|
300
439
|
if (/not found|ENOENT/i.test(error.message)) return 404;
|
|
301
440
|
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;
|
|
302
441
|
return 500;
|
package/src/setup.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { applyResourceBatch } from "./files.js";
|
|
2
2
|
import { createResourceId } from "./id.js";
|
|
3
3
|
import { loadWorkspace } from "./workspace.js";
|
|
4
4
|
|
|
@@ -10,10 +10,19 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
|
10
10
|
const setup = normalizeSetupPayload(payload);
|
|
11
11
|
validateSetup(loaded, setup);
|
|
12
12
|
const plan = buildSetupRecords(loaded, setup);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
const updates = [
|
|
14
|
+
...(plan.existingSystem ? [plan.system] : []),
|
|
15
|
+
plan.workspace,
|
|
16
|
+
...(plan.renderer ? [plan.renderer] : [])
|
|
17
|
+
];
|
|
18
|
+
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, entry.revision]));
|
|
19
|
+
|
|
20
|
+
await applyResourceBatch(loaded.root, {
|
|
21
|
+
create: plan.existingSystem ? [] : [plan.system],
|
|
22
|
+
update: updates,
|
|
23
|
+
expectedRevisions: Object.fromEntries(updates.map((record) => [record.id, revisionById.get(record.id)])),
|
|
24
|
+
validateWholeWorkspace: true
|
|
25
|
+
});
|
|
17
26
|
|
|
18
27
|
return {
|
|
19
28
|
draft: setup.draft,
|
|
@@ -21,7 +30,6 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
|
21
30
|
workspace: plan.workspace,
|
|
22
31
|
renderer: plan.renderer,
|
|
23
32
|
linkedControlIds: [],
|
|
24
|
-
evidenceTestDraftIds: [],
|
|
25
33
|
onboardingComplete: !setup.draft
|
|
26
34
|
};
|
|
27
35
|
}
|
|
@@ -39,8 +47,7 @@ export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
|
|
|
39
47
|
system: plan.existingSystem ? "update" : "create",
|
|
40
48
|
workspace: "update",
|
|
41
49
|
renderer: plan.renderer ? "update" : "unchanged",
|
|
42
|
-
controls: 0
|
|
43
|
-
evidenceDrafts: 0
|
|
50
|
+
controls: 0
|
|
44
51
|
},
|
|
45
52
|
system: setupSystemSummary(plan.system),
|
|
46
53
|
target: setupTargetSummary(plan.workspace),
|
|
@@ -57,8 +64,7 @@ export function summarizeSetupResult(result) {
|
|
|
57
64
|
changes: {
|
|
58
65
|
system: "saved",
|
|
59
66
|
workspace: "updated",
|
|
60
|
-
controls: result.linkedControlIds?.length || 0
|
|
61
|
-
evidenceDrafts: result.evidenceTestDraftIds?.length || 0
|
|
67
|
+
controls: result.linkedControlIds?.length || 0
|
|
62
68
|
},
|
|
63
69
|
system: setupSystemSummary(result.system),
|
|
64
70
|
target: setupTargetSummary(result.workspace),
|
|
@@ -77,7 +83,7 @@ export function normalizeSetupPayload(payload = {}) {
|
|
|
77
83
|
boundary: cleanMultilineText(payload.boundary ?? payload.scope, "boundary"),
|
|
78
84
|
ownerId: cleanText(payload.ownerId ?? payload.owner, "ownerId"),
|
|
79
85
|
criticality: cleanText(payload.criticality, "criticality"),
|
|
80
|
-
|
|
86
|
+
classificationId: cleanText(payload.classificationId, "classificationId"),
|
|
81
87
|
internetExposed: booleanValue(payload.internetExposed, "internetExposed"),
|
|
82
88
|
programGoal: cleanText(payload.programGoal ?? "none", "programGoal"),
|
|
83
89
|
draft,
|
|
@@ -91,7 +97,7 @@ function validateSetup(loaded, setup) {
|
|
|
91
97
|
["boundary", setup.boundary],
|
|
92
98
|
["ownerId", setup.ownerId],
|
|
93
99
|
["criticality", setup.criticality],
|
|
94
|
-
["
|
|
100
|
+
["classificationId", setup.classificationId]
|
|
95
101
|
]) {
|
|
96
102
|
if (!value) throw new Error(`Setup field "${name}" is required.`);
|
|
97
103
|
}
|
|
@@ -113,23 +119,24 @@ function validateSetup(loaded, setup) {
|
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
const classifications = Object.keys(loaded.workspace.classificationDefinitions || {});
|
|
116
|
-
if (classifications.length && !classifications.includes(setup.
|
|
117
|
-
throw new Error(`
|
|
122
|
+
if (classifications.length && !classifications.includes(setup.classificationId)) {
|
|
123
|
+
throw new Error(`classificationId must be one of ${classifications.join(", ")}.`);
|
|
118
124
|
}
|
|
119
125
|
}
|
|
120
126
|
|
|
121
|
-
function findSetupSystem(resources, setup) {
|
|
127
|
+
function findSetupSystem(resources, workspace, setup) {
|
|
128
|
+
const scopedSystemIds = new Set(workspace.systemIds || []);
|
|
122
129
|
return (setup.systemId && resources.find(({ type, id }) => type === "system" && id === setup.systemId))
|
|
123
|
-
|| resources.find(({ type,
|
|
130
|
+
|| resources.find(({ type, id, title, status }) => (
|
|
124
131
|
type === "system"
|
|
125
|
-
&&
|
|
132
|
+
&& scopedSystemIds.has(id)
|
|
126
133
|
&& status !== "retired"
|
|
127
134
|
&& title.trim().toLowerCase() === setup.serviceName.toLowerCase()
|
|
128
135
|
));
|
|
129
136
|
}
|
|
130
137
|
|
|
131
138
|
function buildSetupRecords(loaded, setup) {
|
|
132
|
-
const existingSystem = findSetupSystem(loaded.resources, setup);
|
|
139
|
+
const existingSystem = findSetupSystem(loaded.resources, loaded.workspace, setup);
|
|
133
140
|
const systemId = existingSystem?.id || createResourceId(
|
|
134
141
|
"system",
|
|
135
142
|
setup.serviceName,
|
|
@@ -137,7 +144,6 @@ function buildSetupRecords(loaded, setup) {
|
|
|
137
144
|
);
|
|
138
145
|
const system = {
|
|
139
146
|
...(existingSystem || {}),
|
|
140
|
-
schemaVersion: 1,
|
|
141
147
|
id: systemId,
|
|
142
148
|
type: "system",
|
|
143
149
|
title: setup.serviceName,
|
|
@@ -150,9 +156,8 @@ function buildSetupRecords(loaded, setup) {
|
|
|
150
156
|
ownerIds: [setup.ownerId],
|
|
151
157
|
description: setup.boundary,
|
|
152
158
|
systemKind: existingSystem?.systemKind || "service",
|
|
153
|
-
|
|
154
|
-
internetExposed: setup.internetExposed
|
|
155
|
-
inScope: true
|
|
159
|
+
classificationId: setup.classificationId,
|
|
160
|
+
internetExposed: setup.internetExposed
|
|
156
161
|
};
|
|
157
162
|
const existingWorkspace = loaded.resources.find(({ type }) => type === "workspace");
|
|
158
163
|
if (!existingWorkspace) throw new Error("The workspace settings record was not found.");
|
|
@@ -166,12 +171,6 @@ function buildSetupRecords(loaded, setup) {
|
|
|
166
171
|
return { existingSystem, system, workspace, renderer };
|
|
167
172
|
}
|
|
168
173
|
|
|
169
|
-
async function upsertResource(root, existing, record) {
|
|
170
|
-
return existing
|
|
171
|
-
? updateResource(root, record.type, record.id, record)
|
|
172
|
-
: createResource(root, record);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
174
|
function assuranceGoalFromSetup(goal) {
|
|
176
175
|
if (goal === "type-1") return "soc-2-type-1";
|
|
177
176
|
if (goal === "type-2") return "soc-2-type-2";
|
package/src/state.js
CHANGED
|
@@ -1,49 +1,54 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { assessAuditPreparation } from "./audit-preparation.js";
|
|
4
|
-
import { getGitSummary, getWorkspaceHistories } from "./git.js";
|
|
4
|
+
import { getBrowserRepositoryState, getGitSummary, getWorkspaceHistories } from "./git.js";
|
|
5
5
|
import { renderMarkdown } from "./markdown.js";
|
|
6
6
|
import { planObligations } from "./obligations.js";
|
|
7
7
|
import { resolveDataPath } from "./paths.js";
|
|
8
8
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
9
9
|
import { markdownEntries } from "./resource-markdown.js";
|
|
10
10
|
import { currentCalendarDate } from "./time.js";
|
|
11
|
-
import {
|
|
11
|
+
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
12
|
+
import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
|
|
13
|
+
|
|
14
|
+
const renderedMarkdownCache = new Map();
|
|
15
|
+
const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
|
|
12
16
|
|
|
13
17
|
export async function createAppState(input = process.cwd(), options = {}) {
|
|
14
|
-
|
|
18
|
+
return serializeWorkspaceMutation(input, (root) => createAppStateUnlocked(root, options));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function createAppStateUnlocked(input, options) {
|
|
22
|
+
let validation;
|
|
23
|
+
if (options.validationProof) {
|
|
24
|
+
const current = await fingerprintWorkspace(input);
|
|
25
|
+
validation = current.fingerprint === options.validationProof.fingerprint
|
|
26
|
+
? { ...options.validationProof.validation, loaded: current.loaded }
|
|
27
|
+
: await validateWorkspace(current.loaded);
|
|
28
|
+
} else {
|
|
29
|
+
validation = await validateWorkspace(input);
|
|
30
|
+
}
|
|
15
31
|
const { loaded } = validation;
|
|
16
32
|
const entries = [];
|
|
17
|
-
const
|
|
18
|
-
const histories =
|
|
33
|
+
const includeDetails = options.includeDetails !== false;
|
|
34
|
+
const histories = includeDetails
|
|
35
|
+
? getWorkspaceHistories(loaded.root, loaded.entries.map((entry) => `data/${entry.relativePath}`), 12)
|
|
36
|
+
: new Map();
|
|
19
37
|
|
|
20
38
|
for (const entry of loaded.entries) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (loaded.model.resources[record.type]) {
|
|
24
|
-
for (const item of markdownEntries(loaded.model, record)) {
|
|
25
|
-
try {
|
|
26
|
-
const path = resolveDataPath(loaded.root, item.path);
|
|
27
|
-
const source = await readFile(path, "utf8");
|
|
28
|
-
content[item.name] = { source, html: renderMarkdown(source), path: item.path, revision: contentRevision(source) };
|
|
29
|
-
} catch {
|
|
30
|
-
// Validation reports missing required Markdown.
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
entries.push({
|
|
35
|
-
record,
|
|
36
|
-
relativePath: `data/${entry.relativePath}`,
|
|
37
|
-
revision: contentRevision(entry.source),
|
|
38
|
-
content,
|
|
39
|
+
entries.push(await createStateEntry(loaded, entry, {
|
|
40
|
+
includeDetails,
|
|
39
41
|
history: histories.get(`data/${entry.relativePath}`) ?? []
|
|
40
|
-
});
|
|
42
|
+
}));
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
const git = getGitSummary(loaded.root);
|
|
44
46
|
delete git.root;
|
|
47
|
+
const repository = await getBrowserRepositoryState(loaded.root, {
|
|
48
|
+
readOnly: options.readOnly,
|
|
49
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
50
|
+
});
|
|
45
51
|
const workspace = loaded.workspace ?? {
|
|
46
|
-
schemaVersion: 1,
|
|
47
52
|
dataModelVersion: loaded.model.modelVersion,
|
|
48
53
|
id: "workspace",
|
|
49
54
|
type: "workspace",
|
|
@@ -70,7 +75,9 @@ export async function createAppState(input = process.cwd(), options = {}) {
|
|
|
70
75
|
));
|
|
71
76
|
return {
|
|
72
77
|
generatedAt,
|
|
73
|
-
|
|
78
|
+
asOf,
|
|
79
|
+
readOnly: Boolean(options.readOnly || (repository.mode === "trunk" && !repository.writesAllowed)),
|
|
80
|
+
repository,
|
|
74
81
|
workspace,
|
|
75
82
|
model: loaded.model,
|
|
76
83
|
resources: entries,
|
|
@@ -79,13 +86,72 @@ export async function createAppState(input = process.cwd(), options = {}) {
|
|
|
79
86
|
counts: validation.counts,
|
|
80
87
|
diagnostics: validation.diagnostics
|
|
81
88
|
},
|
|
82
|
-
obligations: planObligations(entries, { asOf, now: options.now ?? generatedAt }),
|
|
89
|
+
obligations: planObligations(entries, { asOf, now: options.now ?? generatedAt, model: loaded.model }),
|
|
83
90
|
programReadiness,
|
|
84
91
|
auditPreparations,
|
|
85
92
|
git
|
|
86
93
|
};
|
|
87
94
|
}
|
|
88
95
|
|
|
96
|
+
export async function createResourceDetail(input, type, id) {
|
|
97
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
98
|
+
const validation = await validateWorkspace(root);
|
|
99
|
+
const entry = validation.loaded.entries.find(({ record }) => record.type === type && record.id === id);
|
|
100
|
+
if (!entry) return null;
|
|
101
|
+
const relativePath = `data/${entry.relativePath}`;
|
|
102
|
+
const histories = getWorkspaceHistories(validation.loaded.root, [relativePath], 12);
|
|
103
|
+
return createStateEntry(validation.loaded, entry, {
|
|
104
|
+
includeDetails: true,
|
|
105
|
+
history: histories.get(relativePath) ?? []
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function createStateEntry(loaded, entry, options) {
|
|
111
|
+
const record = structuredClone(entry.record);
|
|
112
|
+
const content = {};
|
|
113
|
+
if (loaded.model.resources[record.type]) {
|
|
114
|
+
for (const item of markdownEntries(loaded.model, record)) {
|
|
115
|
+
try {
|
|
116
|
+
const path = resolveDataPath(loaded.root, item.path);
|
|
117
|
+
const source = await readFile(path, "utf8");
|
|
118
|
+
content[item.name] = {
|
|
119
|
+
source,
|
|
120
|
+
...(options.includeDetails ? { html: renderMarkdownCached(source) } : {}),
|
|
121
|
+
path: item.path,
|
|
122
|
+
revision: contentRevision(source)
|
|
123
|
+
};
|
|
124
|
+
} catch {
|
|
125
|
+
// Validation reports missing required Markdown.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
record,
|
|
131
|
+
relativePath: `data/${entry.relativePath}`,
|
|
132
|
+
revision: contentRevision(entry.source),
|
|
133
|
+
content,
|
|
134
|
+
history: options.includeDetails ? options.history : undefined,
|
|
135
|
+
detailsLoaded: options.includeDetails
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderMarkdownCached(source) {
|
|
140
|
+
const revision = contentRevision(source);
|
|
141
|
+
const cached = renderedMarkdownCache.get(revision);
|
|
142
|
+
if (cached !== undefined) {
|
|
143
|
+
renderedMarkdownCache.delete(revision);
|
|
144
|
+
renderedMarkdownCache.set(revision, cached);
|
|
145
|
+
return cached;
|
|
146
|
+
}
|
|
147
|
+
const html = renderMarkdown(source);
|
|
148
|
+
renderedMarkdownCache.set(revision, html);
|
|
149
|
+
if (renderedMarkdownCache.size > MAX_RENDERED_MARKDOWN_CACHE_ENTRIES) {
|
|
150
|
+
renderedMarkdownCache.delete(renderedMarkdownCache.keys().next().value);
|
|
151
|
+
}
|
|
152
|
+
return html;
|
|
153
|
+
}
|
|
154
|
+
|
|
89
155
|
function contentRevision(source) {
|
|
90
156
|
return createHash("sha256").update(source).digest("hex");
|
|
91
157
|
}
|
package/src/timing.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
|
|
4
|
+
const timingContext = new AsyncLocalStorage();
|
|
5
|
+
|
|
6
|
+
export async function collectTimings(task) {
|
|
7
|
+
const timings = new Map();
|
|
8
|
+
const result = await timingContext.run(timings, task);
|
|
9
|
+
return { result, timings: Object.fromEntries(timings) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function measureTiming(name, task) {
|
|
13
|
+
const started = performance.now();
|
|
14
|
+
try {
|
|
15
|
+
return await task();
|
|
16
|
+
} finally {
|
|
17
|
+
recordTiming(name, performance.now() - started);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function measureTimingSync(name, task) {
|
|
22
|
+
const started = performance.now();
|
|
23
|
+
try {
|
|
24
|
+
return task();
|
|
25
|
+
} finally {
|
|
26
|
+
recordTiming(name, performance.now() - started);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function recordTiming(name, durationMs) {
|
|
31
|
+
const timings = timingContext.getStore();
|
|
32
|
+
if (!timings) return;
|
|
33
|
+
const current = timings.get(name) ?? { count: 0, durationMs: 0 };
|
|
34
|
+
current.count += 1;
|
|
35
|
+
current.durationMs += durationMs;
|
|
36
|
+
timings.set(name, current);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function timingEnabled() {
|
|
40
|
+
return process.env.FILEGRC_TIMING === "1";
|
|
41
|
+
}
|