filegrc 0.7.0 → 0.8.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/program.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { modelSupports } from "../model/index.js";
2
+
1
3
  export function resolveProgram(loaded, requestedId) {
2
- if (String(loaded.model.modelVersion) !== "4") return loaded.workspace;
4
+ if (!modelSupports(loaded.model, "program-scope")) return loaded.workspace;
3
5
  const programs = loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
4
6
  if (requestedId) {
5
7
  const program = programs.find(({ id }) => id === requestedId);
@@ -33,7 +35,7 @@ export function resolveProgram(loaded, requestedId) {
33
35
  }
34
36
 
35
37
  export function selectedRequirementIds(program, model) {
36
- if (String(model.modelVersion) === "4") {
38
+ if (modelSupports(model, "program-scope")) {
37
39
  return (program.requirementApplicability || [])
38
40
  .filter(({ decision }) => decision === "applicable")
39
41
  .map(({ requirementId }) => requirementId);
@@ -42,7 +44,7 @@ export function selectedRequirementIds(program, model) {
42
44
  }
43
45
 
44
46
  export function programComponents(loaded, program) {
45
- if (String(loaded.model.modelVersion) !== "4") return [];
47
+ if (!modelSupports(loaded.model, "program-scope")) return [];
46
48
  const systemIds = new Set(program.systemIds || []);
47
49
  return loaded.resources.filter((record) => (
48
50
  record.type === "component"
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
+ import { modelSupports } from "../model/index.js";
5
6
  import { createObligationEvent } from "./obligations.js";
6
7
  import { markdownEntries } from "./resource-markdown.js";
7
8
  import { loadWorkspace } from "./workspace.js";
@@ -94,13 +95,13 @@ export async function planReconciliation(input = process.cwd()) {
94
95
  const loaded = input?.resources && input?.model && input?.entries
95
96
  ? input
96
97
  : await loadWorkspace(input);
97
- if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
98
+ if (!modelSupports(loaded.model, "guided-workflow")) {
98
99
  return {
99
100
  contractVersion: 1,
100
101
  gitRevision: gitRevision(loaded.root),
101
102
  changedPaths: [],
102
103
  candidates: [],
103
- message: "Direct-file transition reconciliation is available in model v3 and v4 workspaces."
104
+ message: "Direct-file transition reconciliation is available in model v3 and newer workspaces."
104
105
  };
105
106
  }
106
107
  const changedPaths = gitChangedPaths(loaded.root);
package/src/server.js CHANGED
@@ -5,6 +5,7 @@ import { extname, join, resolve } from "node:path";
5
5
  import { performance } from "node:perf_hooks";
6
6
  import { getResourceDefinition } from "../model/index.js";
7
7
  import { prepareAuditWorkspace } from "./audit-preparation.js";
8
+ import { activateDocuments } from "./document-activation.js";
8
9
  import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
9
10
  import { applyApplicabilityReviewWithContext, planApplicabilityReview } from "./batch-review.js";
10
11
  import { applyCollectionReview, planCollectionReview } from "./collection-review.js";
@@ -330,6 +331,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
330
331
  }, () => activatePolicies(input, { ...payload, confirmed: true }));
331
332
  return json(response, 200, result);
332
333
  }
334
+ if (request.method === "POST" && url.pathname === "/api/document-activations") {
335
+ const payload = await readJson(request);
336
+ const result = await browserMutation(input, options, {
337
+ message: (activation) => `Activate ${activation.documentIds.length} governed ${activation.documentIds.length === 1 ? "Document" : "Documents"}`
338
+ }, () => activateDocuments(input, { ...payload, confirmed: true }));
339
+ return json(response, 200, result);
340
+ }
333
341
  if (request.method === "POST" && url.pathname === "/api/resources") {
334
342
  const payload = normalizeResourceMutation(await readJson(request));
335
343
  const { record } = payload;
@@ -543,13 +551,8 @@ function browserMutation(input, options, mutationOptions, task) {
543
551
  includeDetails: false,
544
552
  validationProof: result?.[BROWSER_VALIDATION]
545
553
  }));
546
- if (result?.synchronization?.status === "syncing" && state.repository.status !== "syncing") {
547
- result.synchronization = {
548
- ...result.synchronization,
549
- status: state.repository.status === "synced" ? "synced" : "not-synced",
550
- synchronizedAt: state.repository.lastSuccessfulSynchronization ?? null,
551
- pushError: state.repository.backgroundSyncError ?? null
552
- };
554
+ if (result?.synchronization) {
555
+ result.synchronization = reconcileMutationSynchronization(result.synchronization, state.repository);
553
556
  }
554
557
  return {
555
558
  ...result,
@@ -564,6 +567,25 @@ function browserMutation(input, options, mutationOptions, task) {
564
567
  });
565
568
  }
566
569
 
570
+ export function reconcileMutationSynchronization(synchronization, repository) {
571
+ if (synchronization?.status !== "syncing" || repository.status === "syncing") return synchronization;
572
+ const backgroundFailed = repository.backgroundSynchronization?.status === "failed";
573
+ if (repository.status !== "synced" && !backgroundFailed) {
574
+ // A repository snapshot can finish just before a fast background push while
575
+ // its state is inspected just after the push. Keep the queued result until
576
+ // a verified success or failure replaces it.
577
+ return synchronization;
578
+ }
579
+ return {
580
+ ...synchronization,
581
+ status: repository.status === "synced" ? "synced" : "not-synced",
582
+ synchronizedAt: repository.lastSuccessfulSynchronization ?? null,
583
+ pushError: repository.backgroundSyncError
584
+ ?? repository.backgroundSynchronization?.error
585
+ ?? null
586
+ };
587
+ }
588
+
567
589
  function prefersFastMutation(request) {
568
590
  return String(request.headers.prefer || "")
569
591
  .split(",")
package/src/setup.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { modelSupports } from "../model/index.js";
1
2
  import { applyResourceBatch } from "./files.js";
2
3
  import { createResourceId } from "./id.js";
3
4
  import { resolveProgram, selectedRequirementIds } from "./program.js";
@@ -9,6 +10,7 @@ const CRITICALITIES = new Set(["low", "medium", "high", "critical"]);
9
10
  export async function setupWorkspace(input = process.cwd(), payload = {}) {
10
11
  const loaded = await loadWorkspace(input);
11
12
  const setup = normalizeSetupPayload(payload);
13
+ setup.classificationId = resolveClassificationId(loaded, setup.classificationId);
12
14
  validateSetup(loaded, setup);
13
15
  const plan = buildSetupRecords(loaded, setup);
14
16
  const updates = [
@@ -44,6 +46,7 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
44
46
  export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
45
47
  const loaded = await loadWorkspace(input);
46
48
  const setup = normalizeSetupPayload(payload);
49
+ setup.classificationId = resolveClassificationId(loaded, setup.classificationId);
47
50
  validateSetup(loaded, setup);
48
51
  const plan = buildSetupRecords(loaded, setup);
49
52
  return {
@@ -77,7 +80,9 @@ export function summarizeSetupResult(result) {
77
80
  commitment: result.commitment ? "saved" : "unchanged"
78
81
  },
79
82
  system: setupSystemSummary(result.system),
80
- target: setupTargetSummary(result.program || result.workspace, { modelVersion: result.program ? "4" : "3" }),
83
+ target: setupTargetSummary(result.program || result.workspace, {
84
+ modelVersion: result.workspace?.dataModelVersion || (result.program ? "5" : "3")
85
+ }),
81
86
  renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
82
87
  commitment: result.commitment || null,
83
88
  onboardingComplete: result.onboardingComplete
@@ -129,7 +134,7 @@ function validateSetup(loaded, setup) {
129
134
  throw new Error(`System "${setup.systemId}" cannot be used for initial scope because it is ${system.status}.`);
130
135
  }
131
136
  }
132
- const classifications = String(loaded.model.modelVersion) === "4"
137
+ const classifications = modelSupports(loaded.model, "program-scope")
133
138
  ? loaded.resources.filter(({ type, status }) => type === "classification" && status === "active").map(({ id }) => id)
134
139
  : Object.keys(loaded.workspace.classificationDefinitions || {});
135
140
  if (classifications.length && !classifications.includes(setup.classificationId)) {
@@ -137,6 +142,21 @@ function validateSetup(loaded, setup) {
137
142
  }
138
143
  }
139
144
 
145
+ function resolveClassificationId(loaded, value) {
146
+ const normalized = String(value || "").trim().toLowerCase();
147
+ if (!normalized) return value;
148
+ const candidates = modelSupports(loaded.model, "program-scope")
149
+ ? loaded.resources
150
+ .filter(({ type, status }) => type === "classification" && status === "active")
151
+ .map(({ id, title }) => ({ id, label: title }))
152
+ : Object.entries(loaded.workspace.classificationDefinitions || {})
153
+ .map(([id, label]) => ({ id, label }));
154
+ const matches = candidates.filter(({ id, label }) => (
155
+ id.toLowerCase() === normalized || String(label || "").trim().toLowerCase() === normalized
156
+ ));
157
+ return matches.length === 1 ? matches[0].id : value;
158
+ }
159
+
140
160
  function findSetupSystem(resources, target, setup) {
141
161
  const scopedSystemIds = new Set(target.systemIds || []);
142
162
  return (setup.systemId && resources.find(({ type, id }) => type === "system" && id === setup.systemId))
@@ -150,7 +170,7 @@ function findSetupSystem(resources, target, setup) {
150
170
 
151
171
  function buildSetupRecords(loaded, setup) {
152
172
  const target = resolveProgram(loaded);
153
- const v4 = String(loaded.model.modelVersion) === "4";
173
+ const v4 = modelSupports(loaded.model, "program-scope");
154
174
  const existingSystem = findSetupSystem(loaded.resources, target, setup);
155
175
  const systemId = existingSystem?.id || createResourceId(
156
176
  "system",
@@ -216,7 +236,7 @@ function buildSetupRecords(loaded, setup) {
216
236
  && !["superseded", "retired"].includes(record.status)
217
237
  && (record.systemIds || []).includes(systemId)
218
238
  ));
219
- const commitment = ["3", "4"].includes(String(loaded.model.modelVersion)) && !existingCommitment
239
+ const commitment = modelSupports(loaded.model, "guided-workflow") && !existingCommitment
220
240
  ? {
221
241
  id: createResourceId(
222
242
  "commitment",
@@ -227,7 +247,7 @@ function buildSetupRecords(loaded, setup) {
227
247
  title: `${setup.serviceName} service commitment`,
228
248
  status: "planned",
229
249
  commitmentKind: "service",
230
- statement: "Replace this starter with the actual customer promise or approved service requirement before activation.",
250
+ statement: "[Complete before activation: State the actual customer promise or approved service requirement.]",
231
251
  systemIds: [systemId],
232
252
  ownerIds: [setup.ownerId],
233
253
  customerFacing: true,
package/src/soc2.js ADDED
@@ -0,0 +1,228 @@
1
+ import { modelSupports } from "../model/index.js";
2
+ import { coverageEnd, coverageStart } from "./coverage.js";
3
+
4
+ export const REQUIRED_SOC2_DESCRIPTION_REFERENCES = Array.from(
5
+ { length: 9 },
6
+ (_, index) => `DC${index + 1}`
7
+ );
8
+
9
+ export const REQUIRED_SOC2_SECURITY_REFERENCES = [
10
+ "CC1.1",
11
+ "CC1.2",
12
+ "CC1.3",
13
+ "CC1.4",
14
+ "CC1.5",
15
+ "CC2.1",
16
+ "CC2.2",
17
+ "CC2.3",
18
+ "CC3.1",
19
+ "CC3.2",
20
+ "CC3.3",
21
+ "CC3.4",
22
+ "CC4.1",
23
+ "CC4.2",
24
+ "CC5.1",
25
+ "CC5.2",
26
+ "CC5.3",
27
+ "CC6.1",
28
+ "CC6.2",
29
+ "CC6.3",
30
+ "CC6.4",
31
+ "CC6.5",
32
+ "CC6.6",
33
+ "CC6.7",
34
+ "CC6.8",
35
+ "CC7.1",
36
+ "CC7.2",
37
+ "CC7.3",
38
+ "CC7.4",
39
+ "CC7.5",
40
+ "CC8.1",
41
+ "CC9.1",
42
+ "CC9.2"
43
+ ];
44
+
45
+ const SOC2_PROGRAM_GOALS = new Set([
46
+ "readiness",
47
+ "soc-2-type-1",
48
+ "soc-2-type-2"
49
+ ]);
50
+
51
+ export function soc2RequirementApplicabilityConstraint(requirement, program, modelVersion = "4") {
52
+ if (
53
+ !modelSupports(modelVersion, "program-scope")
54
+ || requirement?.type !== "requirement"
55
+ || !SOC2_PROGRAM_GOALS.has(program?.assuranceGoal)
56
+ ) return null;
57
+ const reference = String(requirement.reference || "").trim().toUpperCase();
58
+ const security = REQUIRED_SOC2_SECURITY_REFERENCES.includes(reference);
59
+ const description = REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(reference);
60
+ if (!security && !description) return null;
61
+ const family = security ? "Security Common Criteria" : "SOC 2 Description Criteria";
62
+ return {
63
+ requiredDecision: "applicable",
64
+ allowedDecisions: ["applicable"],
65
+ message: `${reference} is required for the selected SOC 2 Security program.`,
66
+ defaultRationale: `${reference} is part of the required ${family} baseline for the selected ${soc2GoalLabel(program.assuranceGoal)} Program.`
67
+ };
68
+ }
69
+
70
+ function soc2GoalLabel(goal) {
71
+ if (goal === "soc-2-type-1") return "SOC 2 Type 1";
72
+ if (goal === "soc-2-type-2") return "SOC 2 Type 2";
73
+ return "SOC 2 readiness";
74
+ }
75
+
76
+ export function missingSoc2References(requirements, requiredReferences) {
77
+ const references = new Set(requirements.map(({ reference }) => String(reference || "").trim().toUpperCase()));
78
+ return requiredReferences.filter((reference) => !references.has(reference));
79
+ }
80
+
81
+ export function recordWasInUseDuringAudit(record, engagementStart, engagementEnd) {
82
+ if (!record) return false;
83
+ if (record.type === "vendor") {
84
+ if (!["active", "deprecated", "terminated"].includes(record.status)) return false;
85
+ const endedOn = record.endDate || (record.status === "terminated" ? record.statusTransition?.changedOn : null);
86
+ if (engagementEnd && record.startDate && record.startDate > engagementEnd) return false;
87
+ if (engagementStart && endedOn && endedOn < engagementStart) return false;
88
+ if (record.status === "terminated" && engagementStart && !endedOn) return false;
89
+ return true;
90
+ }
91
+ if (["active", "deprecated"].includes(record.status)) return true;
92
+ if (!engagementStart) return false;
93
+ if (["component", "system"].includes(record.type) && record.status === "retired") {
94
+ return Boolean(record.statusTransition?.changedOn && record.statusTransition.changedOn >= engagementStart);
95
+ }
96
+ return false;
97
+ }
98
+
99
+ export function auditorWasEngaged(auditor, audit) {
100
+ const engagementStart = audit?.managementAcknowledgedOn
101
+ || audit?.fieldworkStart
102
+ || coverageStart(audit?.coverage);
103
+ const engagementEnd = audit?.reportDate
104
+ || audit?.fieldworkEnd
105
+ || coverageEnd(audit?.coverage);
106
+ if (!recordWasInUseDuringAudit(auditor, engagementStart, engagementEnd)) return false;
107
+ const endedOn = auditor.endDate || (auditor.status === "terminated" ? auditor.statusTransition?.changedOn : null);
108
+ if (engagementStart && auditor.startDate && auditor.startDate > engagementStart) return false;
109
+ if (engagementEnd && endedOn && endedOn < engagementEnd) return false;
110
+ return true;
111
+ }
112
+
113
+ export function personWasActiveOn(person, on) {
114
+ if (person?.type !== "person" || !on) return false;
115
+ if (person.startDate && person.startDate > on) return false;
116
+ const endedOn = person.endDate || (person.status === "inactive" ? person.statusTransition?.changedOn : null);
117
+ if (endedOn && endedOn < on) return false;
118
+ return person.status === "active" || (person.status === "inactive" && Boolean(endedOn));
119
+ }
120
+
121
+ export function appointmentWasAuthorizedOn(appointment, on, byId) {
122
+ if (appointment?.type !== "appointment" || !on || !["active", "ended"].includes(appointment.status)) return false;
123
+ if (!appointment.startsOn || appointment.startsOn > on) return false;
124
+ if (appointment.endsOn && appointment.endsOn < on) return false;
125
+ return personWasActiveOn(byId.get(appointment.holderId), on);
126
+ }
127
+
128
+ export function signatoryAppointmentIssue(audit, byId) {
129
+ if (!audit?.reportDate) {
130
+ return {
131
+ code: "signatory-authority-date-missing",
132
+ message: "Record the CPA report date before confirming who had authority to sign management's assertion and written representations."
133
+ };
134
+ }
135
+ const ids = audit.signatoryAppointmentIds || [];
136
+ if (!ids.length) {
137
+ return {
138
+ code: "signatory-authority-missing",
139
+ message: "Link the dated authority Appointment for each management assertion and representation signer."
140
+ };
141
+ }
142
+ const permittedScopes = new Set([
143
+ "workspace",
144
+ audit.id,
145
+ audit.programId,
146
+ ...(audit.systemIds || []),
147
+ ...[...byId.values()].filter((record) => record.type === "organization").map(({ id }) => id)
148
+ ].filter(Boolean));
149
+ const invalid = ids.filter((id) => {
150
+ const appointment = byId.get(id);
151
+ return !appointmentWasAuthorizedOn(appointment, audit.reportDate, byId)
152
+ || !(appointment.scopeResourceIds || []).some((scopeId) => permittedScopes.has(scopeId));
153
+ });
154
+ if (invalid.length) {
155
+ return {
156
+ code: "signatory-authority-invalid",
157
+ message: `Confirm that ${invalid.join(", ")} names a dated Appointment whose holder was active and whose scope covered the workspace, Program, or Audit on ${audit.reportDate}.`
158
+ };
159
+ }
160
+ return null;
161
+ }
162
+
163
+ export function soc2ReportEvidenceIssue(evidence, audit, modelVersion = "4") {
164
+ if (
165
+ evidence?.type !== "evidence"
166
+ || evidence.status !== "verified"
167
+ || evidence.artifactKind !== "third-party-report"
168
+ || evidence.artifactSubtype !== "soc2-report"
169
+ ) {
170
+ return {
171
+ code: "invalid-audit-report-evidence",
172
+ message: `${evidence?.title || audit?.title || "The audit"} must be verified third-party-report Evidence with subtype soc2-report for the issued SOC 2 report.`
173
+ };
174
+ }
175
+ const issuedOn = (modelSupports(modelVersion, "program-scope")
176
+ ? evidence.sourceGeneratedAt
177
+ : evidence.sourceGeneratedAt || evidence.businessEventAt || evidence.collectedOn
178
+ )?.slice(0, 10);
179
+ if (!issuedOn) {
180
+ return {
181
+ code: "audit-report-date-missing",
182
+ message: `Record the issued SOC 2 report's actual issuance timestamp in ${evidence.title}'s sourceGeneratedAt field.`
183
+ };
184
+ }
185
+ if (audit?.reportDate && issuedOn !== audit.reportDate) {
186
+ return {
187
+ code: "audit-report-date-mismatch",
188
+ message: `${evidence.title} is dated ${issuedOn}, which does not match the Audit reportDate ${audit.reportDate}.`
189
+ };
190
+ }
191
+ if (audit?.reportDate && audit.opinionDate && audit.opinionDate !== audit.reportDate) {
192
+ return {
193
+ code: "audit-opinion-date-mismatch",
194
+ message: `The Audit opinionDate ${audit.opinionDate} does not match reportDate ${audit.reportDate}. Reconcile both dates to the issued CPA report.`
195
+ };
196
+ }
197
+ return null;
198
+ }
199
+
200
+ export function subsequentEventsReviewIssue(audit) {
201
+ const review = audit?.subsequentEventsReview;
202
+ if (!review) {
203
+ return {
204
+ code: "subsequent-events-review-missing",
205
+ message: "Review incidents, changes, findings, fraud, legal matters, subservice coverage, representations, and other relevant events through the CPA report date."
206
+ };
207
+ }
208
+ if (!(review.reviewedByIds || []).length || !String(review.conclusion || "").trim()) {
209
+ return {
210
+ code: "subsequent-events-review-incomplete",
211
+ message: "The subsequent-events review must name its reviewers and record management's conclusion."
212
+ };
213
+ }
214
+ const requiredThroughOn = audit.reportDate || audit.fieldworkEnd || audit.coverage?.endsOn || audit.coverage?.on;
215
+ if (!review.throughOn || (requiredThroughOn && review.throughOn < requiredThroughOn)) {
216
+ return {
217
+ code: "subsequent-events-period-incomplete",
218
+ message: `The subsequent-events review must cover through ${requiredThroughOn || "the latest engagement date"}${audit.reportDate ? ", the CPA report date" : ""}.`
219
+ };
220
+ }
221
+ if (!review.reviewedOn || review.reviewedOn < review.throughOn) {
222
+ return {
223
+ code: "subsequent-events-review-date-invalid",
224
+ message: "The subsequent-events review date must be on or after the date through which events were reviewed."
225
+ };
226
+ }
227
+ return null;
228
+ }
package/src/state.js CHANGED
@@ -8,11 +8,13 @@ import { planObligations } from "./obligations.js";
8
8
  import { resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
9
9
  import { assessProgramReadiness } from "./program-readiness.js";
10
10
  import { markdownEntries } from "./resource-markdown.js";
11
+ import { resolveProgram } from "./program.js";
11
12
  import { currentCalendarDate } from "./time.js";
12
13
  import { serializeWorkspaceMutation } from "./mutation.js";
13
14
  import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
14
15
  import { assessWorkflow } from "./workflow.js";
15
16
  import { measureTiming } from "./timing.js";
17
+ import { soc2RequirementApplicabilityConstraint } from "./soc2.js";
16
18
 
17
19
  const renderedMarkdownCache = new Map();
18
20
  const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
@@ -82,6 +84,11 @@ async function createAppStateUnlocked(input, options) {
82
84
  asOf,
83
85
  generatedAt
84
86
  }));
87
+ const activeProgram = resolveProgram(loaded);
88
+ const applicabilityConstraints = Object.fromEntries(loaded.resources.flatMap((record) => {
89
+ const constraint = soc2RequirementApplicabilityConstraint(record, activeProgram, loaded.model.modelVersion);
90
+ return constraint ? [[record.id, constraint]] : [];
91
+ }));
85
92
  const audits = loaded.resources.filter((record) => record.type === "audit");
86
93
  const auditPreparations = await measureTiming("state-audit-preparation", async () => Object.fromEntries(await Promise.all(
87
94
  (audits.length ? audits : [null]).map(async (audit) => {
@@ -140,6 +147,7 @@ async function createAppStateUnlocked(input, options) {
140
147
  },
141
148
  obligations,
142
149
  collectionReviews,
150
+ applicabilityConstraints,
143
151
  programReadiness,
144
152
  auditPreparations,
145
153
  workflow,