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/README.md +19 -1
- package/model/v1.json +159 -165
- package/package.json +1 -1
- package/src/agent.js +4 -0
- package/src/audit-preparation.js +217 -116
- package/src/cli.js +264 -7
- package/src/evidence-packet.js +72 -19
- package/src/evidence-tests.js +69 -0
- package/src/git.js +1 -0
- package/src/index.js +11 -0
- package/src/model-docs.js +51 -6
- package/src/obligations.js +87 -11
- package/src/program-lifecycle.js +22 -0
- package/src/program-path.js +275 -0
- package/src/program-readiness.js +635 -0
- package/src/resource-markdown.js +11 -4
- package/src/server.js +8 -0
- package/src/setup.js +187 -0
- package/src/state.js +8 -1
- package/src/validate.js +67 -5
- package/src/web.js +757 -428
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 =
|
|
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
|
|
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
|
|
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,
|
|
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) {
|