filegrc 0.1.0 → 0.3.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.
@@ -8,13 +8,14 @@ export function resourceDataPath(model, record) {
8
8
  return join(definition.collection, recordPath).replaceAll("\\", "/");
9
9
  }
10
10
 
11
- export function markdownSlots(model, type) {
11
+ export function markdownSlots(model, type, record = null) {
12
12
  const definition = getResourceDefinition(model, type);
13
13
  const dedicated = Object.entries(definition.markdown ?? {}).map(([name, slot]) => ({
14
14
  name,
15
15
  label: slot.label ?? humanize(name),
16
16
  primary: Boolean(slot.primary),
17
- required: Boolean(slot.required)
17
+ required: Boolean(slot.required || (record && conditionMatches(record, slot.requiredWhen))),
18
+ requiredWhen: slot.requiredWhen ?? null
18
19
  }));
19
20
  if (dedicated.length) return dedicated;
20
21
  return [{
@@ -26,7 +27,7 @@ export function markdownSlots(model, type) {
26
27
  }
27
28
 
28
29
  export function markdownDataPath(model, record, slotName) {
29
- const slot = markdownSlots(model, record.type).find(({ name }) => name === slotName);
30
+ const slot = markdownSlots(model, record.type, record).find(({ name }) => name === slotName);
30
31
  if (!slot) throw new Error(`Unknown Markdown slot "${slotName}" for ${record.type}.`);
31
32
  const recordPath = resourceDataPath(model, record);
32
33
  const extension = extname(recordPath);
@@ -36,11 +37,17 @@ export function markdownDataPath(model, record, slotName) {
36
37
  }
37
38
 
38
39
  export function markdownEntries(model, record) {
39
- return markdownSlots(model, record.type)
40
+ return markdownSlots(model, record.type, record)
40
41
  .map((slot) => ({ ...slot, path: markdownDataPath(model, record, slot.name) }))
41
42
  .filter(({ path }) => path);
42
43
  }
43
44
 
45
+ function conditionMatches(record, condition) {
46
+ return condition && Object.entries(condition).every(([name, expected]) => (
47
+ Array.isArray(expected) ? expected.includes(record[name]) : record[name] === expected
48
+ ));
49
+ }
50
+
44
51
  export function isMarkdownChoice(value) {
45
52
  return typeof value === "string" && value.startsWith("$markdown:");
46
53
  }
package/src/server.js CHANGED
@@ -4,16 +4,18 @@ 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";
7
8
  import { FAVICON_PNG } from "./favicon.js";
8
9
  import { createResource, deleteResource, updateContent, updateResource } from "./files.js";
9
10
  import { commitAndPushWorkspace, getFileHistory, pullWorkspace, pushWorkspace } from "./git.js";
10
11
  import { completeObligationOccurrence, createObligationEvent, planObligations } from "./obligations.js";
11
12
  import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
12
13
  import { createAppState } from "./state.js";
14
+ import { setupWorkspace } from "./setup.js";
13
15
  import { loadWorkspace } from "./workspace.js";
14
16
  import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
15
17
 
16
- export function createFileGRCServer(input = process.cwd(), options = {}) {
18
+ export function createFilegrcServer(input = process.cwd(), options = {}) {
17
19
  return createHttpServer(async (request, response) => {
18
20
  try {
19
21
  if (!expectedHost(request, options.allowedHosts)) {
@@ -82,6 +84,12 @@ export function createFileGRCServer(input = process.cwd(), options = {}) {
82
84
  if (request.method === "POST" && url.pathname === "/api/audit-preparation") {
83
85
  return json(response, 201, await prepareAuditWorkspace(input, await readJson(request)));
84
86
  }
87
+ if (request.method === "POST" && url.pathname === "/api/evidence-test-drafts") {
88
+ return json(response, 201, await ensureEvidenceTestDrafts(input));
89
+ }
90
+ if (request.method === "POST" && url.pathname === "/api/setup") {
91
+ return json(response, 200, await setupWorkspace(input, await readJson(request)));
92
+ }
85
93
  if (request.method === "POST" && url.pathname === "/api/resources") {
86
94
  const payload = await readJson(request);
87
95
  const record = payload.record ?? payload;
@@ -176,7 +184,7 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
176
184
  }
177
185
  const loaded = await loadWorkspace(input);
178
186
  getResourceDefinition(loaded.model, "workspace");
179
- const server = createFileGRCServer(loaded.root, { allowedHosts: [host] });
187
+ const server = createFilegrcServer(loaded.root, { allowedHosts: [host] });
180
188
  await new Promise((resolve, reject) => {
181
189
  server.once("error", reject);
182
190
  server.listen(port, host, resolve);
package/src/setup.js ADDED
@@ -0,0 +1,187 @@
1
+ import { createResource, updateResource } from "./files.js";
2
+ import { ensureEvidenceTestDrafts } from "./evidence-tests.js";
3
+ import { createResourceId } from "./id.js";
4
+ import { loadWorkspace } from "./workspace.js";
5
+
6
+ const PROGRAM_GOALS = new Set(["none", "readiness", "type-1", "type-2"]);
7
+ const CRITICALITIES = new Set(["low", "medium", "high", "critical"]);
8
+
9
+ export async function setupWorkspace(input = process.cwd(), payload = {}) {
10
+ const loaded = await loadWorkspace(input);
11
+ const setup = normalizeSetupPayload(payload);
12
+ validateSetup(loaded, setup);
13
+
14
+ let current = loaded;
15
+ let system = findSetupSystem(current.resources, setup);
16
+ const systemId = system?.id || createResourceId("system", setup.serviceName, current.resources.map(({ id }) => id));
17
+ system = {
18
+ ...(system || {}),
19
+ schemaVersion: 1,
20
+ id: systemId,
21
+ type: "system",
22
+ title: setup.serviceName,
23
+ status: setup.draft ? system?.status || "planned" : (system?.status === "planned" ? "active" : system?.status || "active"),
24
+ criticality: setup.criticality,
25
+ ownerIds: [setup.ownerId],
26
+ description: setup.boundary,
27
+ systemKind: system?.systemKind || "service",
28
+ dataClassification: setup.dataClassification,
29
+ internetExposed: setup.internetExposed,
30
+ inScope: true
31
+ };
32
+ await upsertResource(current.root, current.resources.find(({ id }) => id === systemId), system);
33
+
34
+ current = await loadWorkspace(current.root);
35
+ const linkedControlIds = [];
36
+ for (const control of current.resources.filter(({ type, status }) => (
37
+ type === "control" && !["not-applicable", "retired"].includes(status)
38
+ ))) {
39
+ if ((control.systemIds || []).includes(systemId)) continue;
40
+ await updateResource(current.root, "control", control.id, {
41
+ ...control,
42
+ systemIds: [...new Set([...(control.systemIds || []), systemId])]
43
+ });
44
+ linkedControlIds.push(control.id);
45
+ }
46
+
47
+ current = await loadWorkspace(current.root);
48
+ const existingWorkspace = current.resources.find(({ type }) => type === "workspace");
49
+ if (!existingWorkspace) throw new Error("The workspace settings record was not found.");
50
+ const workspace = {
51
+ ...existingWorkspace,
52
+ assuranceGoal: assuranceGoalFromSetup(setup.programGoal),
53
+ frameworkIds: current.resources
54
+ .filter(({ type, status }) => type === "framework" && status === "active")
55
+ .map(({ id }) => id),
56
+ requirementIds: current.resources
57
+ .filter(({ type, applicability }) => type === "requirement" && applicability === "applicable")
58
+ .map(({ id }) => id),
59
+ controlIds: current.resources
60
+ .filter(({ type, status }) => type === "control" && !["not-applicable", "retired"].includes(status))
61
+ .map(({ id }) => id),
62
+ systemIds: [...new Set([...(existingWorkspace.systemIds || []), systemId])]
63
+ };
64
+ await updateResource(current.root, "workspace", workspace.id, workspace);
65
+
66
+ current = await loadWorkspace(current.root);
67
+ const renderer = current.resources.find(({ type }) => type === "renderer-settings");
68
+ if (renderer) {
69
+ await updateResource(current.root, renderer.type, renderer.id, {
70
+ ...renderer,
71
+ showOnboarding: setup.draft
72
+ });
73
+ }
74
+ const evidenceTestDrafts = setup.draft
75
+ ? { created: [], existing: [], total: 0 }
76
+ : await ensureEvidenceTestDrafts(current.root);
77
+
78
+ return {
79
+ draft: setup.draft,
80
+ system,
81
+ workspace,
82
+ linkedControlIds,
83
+ evidenceTestDraftIds: evidenceTestDrafts.created.map(({ id }) => id),
84
+ onboardingComplete: !setup.draft
85
+ };
86
+ }
87
+
88
+ export function normalizeSetupPayload(payload = {}) {
89
+ if (!payload || Array.isArray(payload) || typeof payload !== "object") {
90
+ throw new Error("Setup input must be a JSON object.");
91
+ }
92
+ const draft = payload.draft === true;
93
+ return {
94
+ serviceName: cleanText(payload.serviceName, "serviceName"),
95
+ boundary: cleanMultilineText(payload.boundary ?? payload.scope, "boundary"),
96
+ ownerId: cleanText(payload.ownerId ?? payload.owner, "ownerId"),
97
+ criticality: cleanText(payload.criticality, "criticality"),
98
+ dataClassification: cleanText(payload.dataClassification ?? payload.classification, "dataClassification"),
99
+ internetExposed: booleanValue(payload.internetExposed, "internetExposed"),
100
+ programGoal: cleanText(payload.programGoal ?? "none", "programGoal"),
101
+ draft,
102
+ systemId: cleanOptionalText(payload.systemId, "systemId")
103
+ };
104
+ }
105
+
106
+ function validateSetup(loaded, setup) {
107
+ for (const [name, value] of [
108
+ ["serviceName", setup.serviceName],
109
+ ["boundary", setup.boundary],
110
+ ["ownerId", setup.ownerId],
111
+ ["criticality", setup.criticality],
112
+ ["dataClassification", setup.dataClassification]
113
+ ]) {
114
+ if (!value) throw new Error(`Setup field "${name}" is required.`);
115
+ }
116
+ if (setup.serviceName.length > 200) throw new Error("serviceName must be 200 characters or fewer.");
117
+ if (setup.boundary.length > 2_000) throw new Error("boundary must be 2,000 characters or fewer.");
118
+ if (!CRITICALITIES.has(setup.criticality)) {
119
+ throw new Error(`criticality must be one of ${[...CRITICALITIES].join(", ")}.`);
120
+ }
121
+ if (!PROGRAM_GOALS.has(setup.programGoal)) {
122
+ throw new Error(`programGoal must be one of ${[...PROGRAM_GOALS].join(", ")}.`);
123
+ }
124
+ const owner = loaded.resources.find(({ id, type }) => id === setup.ownerId && type === "person");
125
+ if (!owner || owner.status !== "active") throw new Error(`Active person "${setup.ownerId}" was not found.`);
126
+ if (setup.systemId) {
127
+ const system = loaded.resources.find(({ id, type }) => id === setup.systemId && type === "system");
128
+ if (!system) throw new Error(`System "${setup.systemId}" was not found.`);
129
+ if (["deprecated", "retired"].includes(system.status)) {
130
+ throw new Error(`System "${setup.systemId}" cannot be used for initial scope because it is ${system.status}.`);
131
+ }
132
+ }
133
+ const classifications = Object.keys(loaded.workspace.classificationDefinitions || {});
134
+ if (classifications.length && !classifications.includes(setup.dataClassification)) {
135
+ throw new Error(`dataClassification must be one of ${classifications.join(", ")}.`);
136
+ }
137
+ }
138
+
139
+ function findSetupSystem(resources, setup) {
140
+ return (setup.systemId && resources.find(({ type, id }) => type === "system" && id === setup.systemId))
141
+ || resources.find(({ type, title, inScope, status }) => (
142
+ type === "system"
143
+ && inScope === true
144
+ && status !== "retired"
145
+ && title.trim().toLowerCase() === setup.serviceName.toLowerCase()
146
+ ));
147
+ }
148
+
149
+ async function upsertResource(root, existing, record) {
150
+ return existing
151
+ ? updateResource(root, record.type, record.id, record)
152
+ : createResource(root, record);
153
+ }
154
+
155
+ function assuranceGoalFromSetup(goal) {
156
+ if (goal === "type-1") return "soc-2-type-1";
157
+ if (goal === "type-2") return "soc-2-type-2";
158
+ if (goal === "readiness") return "readiness";
159
+ return "none";
160
+ }
161
+
162
+ function booleanValue(value, name) {
163
+ if (value === true || value === false) return value;
164
+ if (value === "true") return true;
165
+ if (value === "false") return false;
166
+ throw new Error(`Setup field "${name}" must be true or false.`);
167
+ }
168
+
169
+ function cleanText(value, name) {
170
+ if (value === undefined || value === null) return "";
171
+ const result = String(value).trim();
172
+ if (/[\u0000-\u001f\u007f]/.test(result)) throw new Error(`Setup field "${name}" contains control characters.`);
173
+ return result;
174
+ }
175
+
176
+ function cleanMultilineText(value, name) {
177
+ if (value === undefined || value === null) return "";
178
+ const result = String(value).trim();
179
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(result)) {
180
+ throw new Error(`Setup field "${name}" contains unsupported control characters.`);
181
+ }
182
+ return result;
183
+ }
184
+
185
+ function cleanOptionalText(value, name) {
186
+ return value === undefined || value === null || value === "" ? "" : cleanText(value, name);
187
+ }
package/src/state.js CHANGED
@@ -5,6 +5,7 @@ import { 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
+ import { assessProgramReadiness } from "./program-readiness.js";
8
9
  import { markdownEntries } from "./resource-markdown.js";
9
10
  import { currentCalendarDate } from "./time.js";
10
11
  import { validateWorkspace } from "./validate.js";
@@ -46,18 +47,23 @@ export async function createAppState(input = process.cwd(), options = {}) {
46
47
  dataModelVersion: loaded.model.modelVersion,
47
48
  id: "workspace",
48
49
  type: "workspace",
49
- title: "FileGRC workspace",
50
+ title: "filegrc workspace",
50
51
  organizationName: "Workspace configuration unavailable",
51
52
  timezone: "UTC"
52
53
  };
53
54
  const asOf = options.asOf ?? currentCalendarDate(workspace.timezone);
54
55
  const generatedAt = new Date().toISOString();
56
+ const programReadiness = await assessProgramReadiness(loaded, {
57
+ asOf,
58
+ generatedAt
59
+ });
55
60
  const audits = loaded.resources.filter((record) => record.type === "audit");
56
61
  const auditPreparations = Object.fromEntries(await Promise.all(
57
62
  (audits.length ? audits : [null]).map(async (audit) => {
58
63
  const preparation = await assessAuditPreparation(loaded, {
59
64
  auditId: audit?.id,
60
- generatedAt
65
+ generatedAt,
66
+ programReadiness
61
67
  });
62
68
  return [audit?.id || "none", preparation];
63
69
  })
@@ -74,6 +80,7 @@ export async function createAppState(input = process.cwd(), options = {}) {
74
80
  diagnostics: validation.diagnostics
75
81
  },
76
82
  obligations: planObligations(entries, { asOf, now: options.now ?? generatedAt }),
83
+ programReadiness,
77
84
  auditPreparations,
78
85
  git
79
86
  };
package/src/validate.js CHANGED
@@ -2,8 +2,10 @@ import { stat } from "node:fs/promises";
2
2
  import { getResourceDefinition } from "../model/index.js";
3
3
  import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
4
4
  import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
5
+ import { obligationIsRunning } from "./program-lifecycle.js";
6
+ import { partyPeople } from "./parties.js";
5
7
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
6
- import { isRfc3339Timestamp } from "./time.js";
8
+ import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
7
9
  import { indexResources, loadWorkspace } from "./workspace.js";
8
10
 
9
11
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -17,6 +19,14 @@ export async function validateWorkspace(input = process.cwd()) {
17
19
  const diagnostics = [...loaded.diagnostics];
18
20
  const { byId } = indexResources(loaded.resources);
19
21
  const seen = new Map();
22
+ const asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC");
23
+ const obligationsByControl = new Map();
24
+ for (const obligation of loaded.resources.filter((record) => record.type === "obligation" && record.status !== "retired")) {
25
+ for (const controlId of obligation.controlIds || []) {
26
+ if (!obligationsByControl.has(controlId)) obligationsByControl.set(controlId, []);
27
+ obligationsByControl.get(controlId).push(obligation);
28
+ }
29
+ }
20
30
 
21
31
  for (const entry of loaded.entries) {
22
32
  const { record } = entry;
@@ -88,6 +98,7 @@ export async function validateWorkspace(input = process.cwd()) {
88
98
  }
89
99
  validateIndependentApproval(record, byId, displayPath, diagnostics);
90
100
  validateCompletedObligationEvent(record, byId, displayPath, diagnostics);
101
+ validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, displayPath, diagnostics);
91
102
  await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
92
103
  }
93
104
 
@@ -104,10 +115,41 @@ export async function validateWorkspace(input = process.cwd()) {
104
115
  };
105
116
  }
106
117
 
118
+ function validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, path, diagnostics) {
119
+ if (record.type !== "control" || record.status !== "implemented") return;
120
+ const schedules = obligationsByControl.get(record.id) || [];
121
+ if (!schedules.length || schedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))) return;
122
+ const stopped = schedules.filter((obligation) => !obligationIsRunning(obligation, byId, asOf));
123
+ const paused = stopped.filter((obligation) => obligation.status === "paused");
124
+ const waiting = stopped.filter((obligation) => obligation.status === "active");
125
+ const policyBlockers = [...new Set(waiting.flatMap((obligation) => (obligation.policyIds || []).map((id) => {
126
+ const policy = byId.get(id);
127
+ if (!policy || policy.type !== "policy") return `${id} (missing)`;
128
+ if (policy.status !== "active") return `${policy.title} (${policy.status})`;
129
+ if (!policy.effectiveOn) return `${policy.title} (effective date missing)`;
130
+ if (policy.effectiveOn > asOf) return `${policy.title} (effective ${policy.effectiveOn})`;
131
+ return null;
132
+ })).filter(Boolean))];
133
+ const reasons = [
134
+ waiting.length
135
+ ? `${waiting.length} enabled ${waiting.length === 1 ? "schedule is" : "schedules are"} waiting for governing ${policyBlockers.length === 1 ? "policy" : "policies"} to become active and effective${policyBlockers.length ? `: ${policyBlockers.join(", ")}` : ""}. Complete Step 2 first.`
136
+ : "",
137
+ paused.length
138
+ ? `${paused.length} linked ${paused.length === 1 ? "schedule is" : "schedules are"} paused. Enable ${paused.length === 1 ? "it" : "them"} before implementing the control.`
139
+ : ""
140
+ ].filter(Boolean).join(" ");
141
+ diagnostics.push(error(
142
+ "control-work-queue-not-running",
143
+ path,
144
+ `This control cannot be marked implemented yet. ${reasons}`
145
+ ));
146
+ }
147
+
107
148
  function validateDateRanges(record, path, diagnostics) {
108
149
  for (const [startField, endField] of [
109
150
  ["startDate", "endDate"],
110
151
  ["periodStart", "periodEnd"],
152
+ ["candidatePeriodStart", "candidatePeriodEnd"],
111
153
  ["dueWindowStart", "dueWindowEnd"]
112
154
  ]) {
113
155
  const start = record[startField];
@@ -131,9 +173,11 @@ function validateCompletedObligationEvent(record, byId, path, diagnostics) {
131
173
  "completedOn cannot be before occurredOn."
132
174
  ));
133
175
  }
134
- const actionIds = record.actionItemIds || [];
176
+ const actionIds = [...byId.values()]
177
+ .filter((candidate) => candidate.type === "action-item" && candidate.sourceResourceId === record.id)
178
+ .map((candidate) => candidate.id);
135
179
  if (actionIds.length === 0) {
136
- diagnostics.push(error("incomplete-obligation-event", path, "A complete obligation event must have action items."));
180
+ diagnostics.push(error("incomplete-obligation-event", path, "A complete Policy Event must have action items."));
137
181
  return;
138
182
  }
139
183
  for (const actionId of actionIds) {
@@ -180,9 +224,10 @@ function validateEvidencePaths(record, path, diagnostics) {
180
224
  }
181
225
 
182
226
  function validateIndependentApproval(record, byId, path, diagnostics) {
183
- if (!["policy", "document"].includes(record.type) || !(record.approverIds || []).length) return;
184
- const owners = expandPeople(record.ownerIds || [], byId);
185
- const approvers = expandPeople(record.approverIds || [], byId);
227
+ if (!["policy", "document"].includes(record.type)) return;
228
+ if (!(record.approverIds || []).length) return;
229
+ const owners = partyPeople(record.ownerIds || [], byId);
230
+ const approvers = partyPeople(record.approverIds || [], byId);
186
231
  const overlap = [...owners].filter((id) => approvers.has(id));
187
232
  if (overlap.length) {
188
233
  diagnostics.push(error(
@@ -193,22 +238,6 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
193
238
  }
194
239
  }
195
240
 
196
- function expandPeople(ids, byId, seen = new Set()) {
197
- const people = new Set();
198
- for (const id of ids) {
199
- if (seen.has(id)) continue;
200
- seen.add(id);
201
- const record = byId.get(id);
202
- if (record?.type === "person") people.add(id);
203
- if (record?.type === "team") {
204
- for (const personId of expandPeople([...(record.memberIds || []), ...(record.chairIds || [])], byId, seen)) {
205
- people.add(personId);
206
- }
207
- }
208
- }
209
- return people;
210
- }
211
-
212
241
  function validateObligation(record, path, diagnostics) {
213
242
  const recurrence = record.recurrence;
214
243
  if (!recurrence || Array.isArray(recurrence) || typeof recurrence !== "object") return;
@@ -364,7 +393,9 @@ async function validateMarkdown(record, definition, model, root, path, diagnosti
364
393
  }
365
394
  }
366
395
 
367
- for (const choices of definition.oneOf ?? []) {
396
+ for (const group of definition.oneOf ?? []) {
397
+ const choices = Array.isArray(group) ? group : group.fields;
398
+ if (!Array.isArray(choices) || (!Array.isArray(group) && !conditionMatches(record, group.when))) continue;
368
399
  const satisfied = choices.some((name) => (
369
400
  isMarkdownChoice(name)
370
401
  ? present.has(name.slice("$markdown:".length))
@@ -480,7 +511,9 @@ function isTimezone(value) {
480
511
  }
481
512
 
482
513
  function conditionMatches(record, condition) {
483
- return Object.entries(condition).every(([name, value]) => record[name] === value);
514
+ return Boolean(condition) && Object.entries(condition).every(([name, expected]) => (
515
+ Array.isArray(expected) ? expected.includes(record[name]) : record[name] === expected
516
+ ));
484
517
  }
485
518
 
486
519
  function findDefinitionType(model, definition) {