filegrc 0.1.0 → 0.2.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/src/server.js CHANGED
@@ -4,12 +4,14 @@ 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
 
@@ -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;
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";
@@ -52,12 +53,17 @@ export async function createAppState(input = process.cwd(), options = {}) {
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,9 @@ 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";
5
6
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
6
- import { isRfc3339Timestamp } from "./time.js";
7
+ import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
7
8
  import { indexResources, loadWorkspace } from "./workspace.js";
8
9
 
9
10
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -17,6 +18,14 @@ export async function validateWorkspace(input = process.cwd()) {
17
18
  const diagnostics = [...loaded.diagnostics];
18
19
  const { byId } = indexResources(loaded.resources);
19
20
  const seen = new Map();
21
+ const asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC");
22
+ const obligationsByControl = new Map();
23
+ for (const obligation of loaded.resources.filter((record) => record.type === "obligation" && record.status !== "retired")) {
24
+ for (const controlId of obligation.controlIds || []) {
25
+ if (!obligationsByControl.has(controlId)) obligationsByControl.set(controlId, []);
26
+ obligationsByControl.get(controlId).push(obligation);
27
+ }
28
+ }
20
29
 
21
30
  for (const entry of loaded.entries) {
22
31
  const { record } = entry;
@@ -88,6 +97,7 @@ export async function validateWorkspace(input = process.cwd()) {
88
97
  }
89
98
  validateIndependentApproval(record, byId, displayPath, diagnostics);
90
99
  validateCompletedObligationEvent(record, byId, displayPath, diagnostics);
100
+ validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, displayPath, diagnostics);
91
101
  await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
92
102
  }
93
103
 
@@ -104,10 +114,41 @@ export async function validateWorkspace(input = process.cwd()) {
104
114
  };
105
115
  }
106
116
 
117
+ function validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, path, diagnostics) {
118
+ if (record.type !== "control" || record.status !== "implemented") return;
119
+ const schedules = obligationsByControl.get(record.id) || [];
120
+ if (!schedules.length || schedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))) return;
121
+ const stopped = schedules.filter((obligation) => !obligationIsRunning(obligation, byId, asOf));
122
+ const paused = stopped.filter((obligation) => obligation.status === "paused");
123
+ const waiting = stopped.filter((obligation) => obligation.status === "active");
124
+ const policyBlockers = [...new Set(waiting.flatMap((obligation) => (obligation.policyIds || []).map((id) => {
125
+ const policy = byId.get(id);
126
+ if (!policy || policy.type !== "policy") return `${id} (missing)`;
127
+ if (policy.status !== "active") return `${policy.title} (${policy.status})`;
128
+ if (!policy.effectiveOn) return `${policy.title} (effective date missing)`;
129
+ if (policy.effectiveOn > asOf) return `${policy.title} (effective ${policy.effectiveOn})`;
130
+ return null;
131
+ })).filter(Boolean))];
132
+ const reasons = [
133
+ waiting.length
134
+ ? `${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.`
135
+ : "",
136
+ paused.length
137
+ ? `${paused.length} linked ${paused.length === 1 ? "schedule is" : "schedules are"} paused. Enable ${paused.length === 1 ? "it" : "them"} before implementing the control.`
138
+ : ""
139
+ ].filter(Boolean).join(" ");
140
+ diagnostics.push(error(
141
+ "control-work-queue-not-running",
142
+ path,
143
+ `This control cannot be marked implemented yet. ${reasons}`
144
+ ));
145
+ }
146
+
107
147
  function validateDateRanges(record, path, diagnostics) {
108
148
  for (const [startField, endField] of [
109
149
  ["startDate", "endDate"],
110
150
  ["periodStart", "periodEnd"],
151
+ ["candidatePeriodStart", "candidatePeriodEnd"],
111
152
  ["dueWindowStart", "dueWindowEnd"]
112
153
  ]) {
113
154
  const start = record[startField];
@@ -131,9 +172,11 @@ function validateCompletedObligationEvent(record, byId, path, diagnostics) {
131
172
  "completedOn cannot be before occurredOn."
132
173
  ));
133
174
  }
134
- const actionIds = record.actionItemIds || [];
175
+ const actionIds = [...byId.values()]
176
+ .filter((candidate) => candidate.type === "action-item" && candidate.sourceResourceId === record.id)
177
+ .map((candidate) => candidate.id);
135
178
  if (actionIds.length === 0) {
136
- diagnostics.push(error("incomplete-obligation-event", path, "A complete obligation event must have action items."));
179
+ diagnostics.push(error("incomplete-obligation-event", path, "A complete Policy Event must have action items."));
137
180
  return;
138
181
  }
139
182
  for (const actionId of actionIds) {
@@ -191,6 +234,21 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
191
234
  `Approvers must be separate from owners, including through team membership: ${overlap.join(", ")}.`
192
235
  ));
193
236
  }
237
+ if (["approved", "active"].includes(record.status)) {
238
+ const incompleteStarterApprover = [...approvers]
239
+ .map((id) => byId.get(id))
240
+ .find((person) => (
241
+ person?.id === "person-independent-approver"
242
+ && ["Independent Approver", "Independent Reviewer"].includes(person.title)
243
+ ));
244
+ if (incompleteStarterApprover) {
245
+ diagnostics.push(error(
246
+ "independent-approver-not-appointed",
247
+ path,
248
+ `The selected approver "${incompleteStarterApprover.title}" is still the starter placeholder. Open People and replace it with the reviewer's actual name before approving this record. The reviewer may be internal or external but must be separate from the owner.`
249
+ ));
250
+ }
251
+ }
194
252
  }
195
253
 
196
254
  function expandPeople(ids, byId, seen = new Set()) {
@@ -364,7 +422,9 @@ async function validateMarkdown(record, definition, model, root, path, diagnosti
364
422
  }
365
423
  }
366
424
 
367
- for (const choices of definition.oneOf ?? []) {
425
+ for (const group of definition.oneOf ?? []) {
426
+ const choices = Array.isArray(group) ? group : group.fields;
427
+ if (!Array.isArray(choices) || (!Array.isArray(group) && !conditionMatches(record, group.when))) continue;
368
428
  const satisfied = choices.some((name) => (
369
429
  isMarkdownChoice(name)
370
430
  ? present.has(name.slice("$markdown:".length))
@@ -480,7 +540,9 @@ function isTimezone(value) {
480
540
  }
481
541
 
482
542
  function conditionMatches(record, condition) {
483
- return Object.entries(condition).every(([name, value]) => record[name] === value);
543
+ return Boolean(condition) && Object.entries(condition).every(([name, expected]) => (
544
+ Array.isArray(expected) ? expected.includes(record[name]) : record[name] === expected
545
+ ));
484
546
  }
485
547
 
486
548
  function findDefinitionType(model, definition) {