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.
- package/LICENSE +1 -1
- package/README.md +24 -5
- package/model/v1.json +163 -169
- package/package.json +2 -2
- package/src/agent.js +4 -0
- package/src/audit-preparation.js +217 -139
- package/src/cli.js +364 -9
- package/src/evidence-packet.js +78 -25
- package/src/evidence-tests.js +69 -0
- package/src/git.js +2 -1
- package/src/index.js +12 -1
- package/src/model-docs.js +51 -6
- package/src/obligations.js +87 -11
- package/src/parties.js +46 -0
- package/src/paths.js +1 -1
- package/src/program-lifecycle.js +25 -0
- package/src/program-path.js +275 -0
- package/src/program-readiness.js +652 -0
- package/src/resource-markdown.js +11 -4
- package/src/server.js +10 -2
- package/src/setup.js +187 -0
- package/src/state.js +9 -2
- package/src/validate.js +57 -24
- package/src/web.js +827 -440
package/src/resource-markdown.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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: "
|
|
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 =
|
|
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
|
|
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)
|
|
184
|
-
|
|
185
|
-
const
|
|
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
|
|
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,
|
|
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) {
|