filegrc 0.3.4 → 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 +16 -6
- package/model/index.js +37 -3
- package/model/v1.json +81 -47
- 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 +168 -110
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +115 -75
- package/src/files.js +230 -28
- package/src/git.js +239 -41
- package/src/index.js +5 -5
- package/src/model-docs.js +88 -7
- package/src/model-migration.js +1463 -0
- package/src/mutation.js +42 -0
- 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 +110 -39
- package/src/setup.js +27 -28
- package/src/state.js +86 -25
- package/src/timing.js +41 -0
- package/src/validate.js +609 -43
- package/src/web.js +506 -129
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
package/src/server.js
CHANGED
|
@@ -4,22 +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
9
|
import {
|
|
10
|
+
BROWSER_VALIDATION,
|
|
11
11
|
commitAndPushWorkspace,
|
|
12
12
|
getBrowserRepositoryState,
|
|
13
13
|
getFileHistory,
|
|
14
|
+
getGitSummary,
|
|
14
15
|
pullWorkspace,
|
|
15
16
|
pushWorkspace,
|
|
16
17
|
retryBrowserSync,
|
|
17
18
|
runBrowserMutation
|
|
18
19
|
} from "./git.js";
|
|
20
|
+
import { normalizeResourceMutation, serializeWorkspaceMutation } from "./mutation.js";
|
|
19
21
|
import { completeObligationOccurrence, createObligationEvent, planObligations } from "./obligations.js";
|
|
20
22
|
import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
|
|
21
|
-
import { createAppState } from "./state.js";
|
|
23
|
+
import { createAppState, createResourceDetail } from "./state.js";
|
|
22
24
|
import { setupWorkspace } from "./setup.js";
|
|
25
|
+
import { collectTimings, measureTiming, timingEnabled } from "./timing.js";
|
|
23
26
|
import { loadWorkspace } from "./workspace.js";
|
|
24
27
|
import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
|
|
25
28
|
|
|
@@ -34,8 +37,17 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
34
37
|
return json(response, 403, { error: "Cross-origin writes are not allowed." });
|
|
35
38
|
}
|
|
36
39
|
if (request.method === "GET" && url.pathname === "/api/state") {
|
|
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
|
+
}
|
|
37
48
|
return json(response, 200, await createAppState(input, {
|
|
38
|
-
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
49
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
50
|
+
includeDetails: false
|
|
39
51
|
}));
|
|
40
52
|
}
|
|
41
53
|
if (request.method === "GET" && url.pathname === "/api/history") {
|
|
@@ -50,7 +62,8 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
50
62
|
from: url.searchParams.get("from") || undefined,
|
|
51
63
|
through: url.searchParams.get("through") || undefined,
|
|
52
64
|
now: url.searchParams.get("now") || undefined,
|
|
53
|
-
includeComplete: url.searchParams.get("includeComplete") === "true"
|
|
65
|
+
includeComplete: url.searchParams.get("includeComplete") === "true",
|
|
66
|
+
model: loaded.model
|
|
54
67
|
}));
|
|
55
68
|
}
|
|
56
69
|
if (request.method === "POST" && url.pathname === "/api/obligation-events") {
|
|
@@ -68,7 +81,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
68
81
|
obligationId: payload.obligationId,
|
|
69
82
|
record: payload.record,
|
|
70
83
|
content: payload.content,
|
|
71
|
-
expectedRevision: payload.revision
|
|
84
|
+
expectedRevision: requireRevision(payload.revision, `obligation/${payload.obligationId}`)
|
|
72
85
|
}));
|
|
73
86
|
return json(response, 201, result);
|
|
74
87
|
}
|
|
@@ -102,49 +115,66 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
102
115
|
message: () => `Prepare audit: ${payload.auditId || "engagement"}`
|
|
103
116
|
}, () => prepareAuditWorkspace(input, payload)));
|
|
104
117
|
}
|
|
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
118
|
if (request.method === "POST" && url.pathname === "/api/setup") {
|
|
111
119
|
const payload = await readJson(request);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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());
|
|
115
126
|
}
|
|
116
127
|
if (request.method === "POST" && url.pathname === "/api/resources") {
|
|
117
|
-
const payload = await readJson(request);
|
|
118
|
-
const record = payload
|
|
128
|
+
const payload = normalizeResourceMutation(await readJson(request));
|
|
129
|
+
const { record } = payload;
|
|
119
130
|
const result = await browserMutation(input, options, {
|
|
120
131
|
message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`
|
|
121
|
-
}, () => createResource(input, record, { content: payload.
|
|
122
|
-
return json(response, 201, { record: result.record, synchronization: result.synchronization });
|
|
132
|
+
}, () => createResource(input, record, { content: payload.content }));
|
|
133
|
+
return json(response, 201, { record: result.record, synchronization: result.synchronization, state: result.state });
|
|
123
134
|
}
|
|
124
135
|
if (request.method === "POST" && url.pathname === "/api/commit") {
|
|
125
136
|
await requireManualBrowserGit(input, options);
|
|
126
137
|
const payload = await readJson(request);
|
|
127
|
-
return json(response, 201, await commitAndPushWorkspace(input, payload.message));
|
|
138
|
+
return json(response, 201, await manualGitResultWithState(input, options, () => commitAndPushWorkspace(input, payload.message)));
|
|
128
139
|
}
|
|
129
140
|
if (request.method === "POST" && url.pathname === "/api/git/pull") {
|
|
130
141
|
await requireManualBrowserGit(input, options);
|
|
131
|
-
return json(response, 200, await pullWorkspace(input));
|
|
142
|
+
return json(response, 200, await manualGitResultWithState(input, options, () => pullWorkspace(input)));
|
|
132
143
|
}
|
|
133
144
|
if (request.method === "POST" && url.pathname === "/api/git/push") {
|
|
134
145
|
await requireManualBrowserGit(input, options);
|
|
135
|
-
return json(response, 200, await pushWorkspace(input));
|
|
146
|
+
return json(response, 200, await manualGitResultWithState(input, options, () => pushWorkspace(input)));
|
|
136
147
|
}
|
|
137
148
|
if (request.method === "POST" && url.pathname === "/api/git/retry-sync") {
|
|
138
|
-
|
|
149
|
+
const result = await retryBrowserSync(input, {
|
|
139
150
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
140
|
-
})
|
|
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
|
+
});
|
|
141
169
|
}
|
|
142
170
|
if (request.method === "PUT" && url.pathname === "/api/content") {
|
|
143
171
|
const payload = await readJson(request);
|
|
144
172
|
const result = await browserMutation(input, options, {
|
|
145
173
|
message: () => `Update content: ${payload.path}`
|
|
146
|
-
}, () => updateContent(input, payload.path, payload.source, {
|
|
147
|
-
|
|
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 });
|
|
148
178
|
}
|
|
149
179
|
const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
|
|
150
180
|
if (match) {
|
|
@@ -152,32 +182,34 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
152
182
|
const id = decodeURIComponent(match[2]);
|
|
153
183
|
if (!safeSegment(type) || !safeSegment(id)) return json(response, 400, { error: "Unsafe resource identifier." });
|
|
154
184
|
if (request.method === "GET") {
|
|
155
|
-
const
|
|
156
|
-
const entry = state.resources.find(({ record }) => record.type === type && record.id === id);
|
|
185
|
+
const entry = await createResourceDetail(input, type, id);
|
|
157
186
|
return entry ? json(response, 200, entry) : json(response, 404, { error: "Resource not found." });
|
|
158
187
|
}
|
|
159
188
|
if (request.method === "PUT") {
|
|
160
|
-
const payload = await readJson(request);
|
|
161
|
-
const record = payload
|
|
189
|
+
const payload = normalizeResourceMutation(await readJson(request), { requireRevision: true });
|
|
190
|
+
const { record } = payload;
|
|
162
191
|
const result = await browserMutation(input, options, {
|
|
163
192
|
message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}`
|
|
164
193
|
}, () => updateResource(input, type, id, record, {
|
|
165
|
-
content: payload.
|
|
194
|
+
content: payload.content,
|
|
166
195
|
expectedRevision: payload.revision,
|
|
167
|
-
expectedContentRevisions: payload.contentRevisions
|
|
196
|
+
expectedContentRevisions: payload.contentRevisions,
|
|
197
|
+
requireExpectedContentRevisions: true
|
|
168
198
|
}));
|
|
169
|
-
return json(response, 200, { record: result.record, synchronization: result.synchronization });
|
|
199
|
+
return json(response, 200, { record: result.record, synchronization: result.synchronization, state: result.state });
|
|
170
200
|
}
|
|
171
201
|
if (request.method === "DELETE") {
|
|
202
|
+
const revision = requireRevision(url.searchParams.get("revision"), `${type}/${id}`);
|
|
172
203
|
const result = await browserMutation(input, options, {
|
|
173
204
|
message: () => `Delete ${resourceTypeLabel(type)}: ${id}`
|
|
174
|
-
}, () => deleteResource(input, type, id, { expectedRevision:
|
|
205
|
+
}, () => deleteResource(input, type, id, { expectedRevision: revision }));
|
|
175
206
|
return json(response, 200, {
|
|
176
207
|
deleted: true,
|
|
177
208
|
type,
|
|
178
209
|
id,
|
|
179
210
|
deletedContent: result.deletedContent,
|
|
180
|
-
synchronization: result.synchronization
|
|
211
|
+
synchronization: result.synchronization,
|
|
212
|
+
state: result.state
|
|
181
213
|
});
|
|
182
214
|
}
|
|
183
215
|
}
|
|
@@ -232,7 +264,8 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
232
264
|
getResourceDefinition(loaded.model, "workspace");
|
|
233
265
|
const server = createFilegrcServer(loaded.root, {
|
|
234
266
|
allowedHosts: [host],
|
|
235
|
-
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
|
|
267
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
|
|
268
|
+
backgroundPushDelayMs: options.backgroundPushDelayMs
|
|
236
269
|
});
|
|
237
270
|
await new Promise((resolve, reject) => {
|
|
238
271
|
server.once("error", reject);
|
|
@@ -247,10 +280,32 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
247
280
|
}
|
|
248
281
|
|
|
249
282
|
function browserMutation(input, options, mutationOptions, task) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
+
});
|
|
254
309
|
}
|
|
255
310
|
|
|
256
311
|
async function requireManualBrowserGit(input, options) {
|
|
@@ -262,6 +317,15 @@ async function requireManualBrowserGit(input, options) {
|
|
|
262
317
|
}
|
|
263
318
|
}
|
|
264
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
|
+
|
|
265
329
|
function resourceTypeLabel(value) {
|
|
266
330
|
return String(value || "record").replaceAll("-", " ");
|
|
267
331
|
}
|
|
@@ -319,6 +383,13 @@ function safeSegment(value) {
|
|
|
319
383
|
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
320
384
|
}
|
|
321
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
|
+
|
|
322
393
|
function sameOrigin(request) {
|
|
323
394
|
const origin = request.headers.origin;
|
|
324
395
|
if (!origin) return true;
|
|
@@ -364,7 +435,7 @@ function statusFor(error) {
|
|
|
364
435
|
if (/exceeds 2 MB/i.test(error.message)) return 413;
|
|
365
436
|
if (/changed after you opened|source changed|revision changed/i.test(error.message)) return 409;
|
|
366
437
|
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;
|
|
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;
|
|
368
439
|
if (/not found|ENOENT/i.test(error.message)) return 404;
|
|
369
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;
|
|
370
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
|
@@ -8,36 +8,38 @@ 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);
|
|
@@ -47,7 +49,6 @@ export async function createAppState(input = process.cwd(), options = {}) {
|
|
|
47
49
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
48
50
|
});
|
|
49
51
|
const workspace = loaded.workspace ?? {
|
|
50
|
-
schemaVersion: 1,
|
|
51
52
|
dataModelVersion: loaded.model.modelVersion,
|
|
52
53
|
id: "workspace",
|
|
53
54
|
type: "workspace",
|
|
@@ -74,6 +75,7 @@ export async function createAppState(input = process.cwd(), options = {}) {
|
|
|
74
75
|
));
|
|
75
76
|
return {
|
|
76
77
|
generatedAt,
|
|
78
|
+
asOf,
|
|
77
79
|
readOnly: Boolean(options.readOnly || (repository.mode === "trunk" && !repository.writesAllowed)),
|
|
78
80
|
repository,
|
|
79
81
|
workspace,
|
|
@@ -84,13 +86,72 @@ export async function createAppState(input = process.cwd(), options = {}) {
|
|
|
84
86
|
counts: validation.counts,
|
|
85
87
|
diagnostics: validation.diagnostics
|
|
86
88
|
},
|
|
87
|
-
obligations: planObligations(entries, { asOf, now: options.now ?? generatedAt }),
|
|
89
|
+
obligations: planObligations(entries, { asOf, now: options.now ?? generatedAt, model: loaded.model }),
|
|
88
90
|
programReadiness,
|
|
89
91
|
auditPreparations,
|
|
90
92
|
git
|
|
91
93
|
};
|
|
92
94
|
}
|
|
93
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
|
+
|
|
94
155
|
function contentRevision(source) {
|
|
95
156
|
return createHash("sha256").update(source).digest("hex");
|
|
96
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
|
+
}
|