filegrc 0.7.1 → 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/README.md +6 -6
- package/model/index.js +21 -4
- package/model/v5.json +10233 -0
- package/package.json +4 -2
- package/src/agent.js +4 -3
- package/src/audit-preparation.js +182 -45
- package/src/audit-transition.js +3 -2
- package/src/batch-review.js +7 -6
- package/src/cli.js +74 -12
- package/src/collection-review.js +4 -3
- package/src/collection-scope.js +4 -3
- package/src/document-activation.js +145 -0
- package/src/evidence-packet.js +199 -78
- package/src/external-reviewer.js +5 -4
- package/src/files.js +141 -32
- package/src/git.js +71 -7
- package/src/index.js +4 -1
- package/src/model-migration.js +218 -7
- package/src/obligations.js +14 -11
- package/src/policy-library.js +2 -1
- package/src/program-lifecycle.js +93 -3
- package/src/program-path.js +13 -8
- package/src/program-readiness.js +235 -71
- package/src/program.js +5 -3
- package/src/reconciliation.js +3 -2
- package/src/server.js +29 -7
- package/src/setup.js +8 -5
- package/src/soc2.js +3 -2
- package/src/validate.js +148 -14
- package/src/web.js +160 -15
- package/src/workflow.js +18 -4
- package/src/workspace.js +5 -0
package/src/web.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createResourceId } from "./id.js";
|
|
2
|
+
import { MODEL_CAPABILITY_VERSIONS } from "../model/index.js";
|
|
2
3
|
import {
|
|
3
4
|
calendarOccurrence,
|
|
4
5
|
calendarOccurrenceIndex,
|
|
@@ -52,6 +53,8 @@ export function renderIndex(state = null) {
|
|
|
52
53
|
export const APP_SCRIPT = String.raw`
|
|
53
54
|
const root = document.querySelector("#app");
|
|
54
55
|
let state;
|
|
56
|
+
const MODEL_CAPABILITY_VERSIONS = ${JSON.stringify(MODEL_CAPABILITY_VERSIONS)};
|
|
57
|
+
const modelSupports = (capability) => Number(state?.model?.modelVersion || 0) >= MODEL_CAPABILITY_VERSIONS[capability];
|
|
55
58
|
const LIST_PAGE_SIZE = 25;
|
|
56
59
|
const SEARCH_PAGE_SIZE = 25;
|
|
57
60
|
const NAV_GROUP_STORAGE_KEY = "filegrc.sidebar.groups.v3";
|
|
@@ -242,7 +245,9 @@ function topbar(route) {
|
|
|
242
245
|
? "Work Queue"
|
|
243
246
|
: route.name === "audit-packet"
|
|
244
247
|
? "Audit Readiness"
|
|
245
|
-
:
|
|
248
|
+
: route.name === "list" && route.type === "document"
|
|
249
|
+
? documentListTitle(route.params)
|
|
250
|
+
: state.model.resources[route.type]?.pluralTitle || "filegrc";
|
|
246
251
|
const repositoryLabel = state.repository?.mode === "trunk"
|
|
247
252
|
? state.repository.label
|
|
248
253
|
: state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable";
|
|
@@ -353,12 +358,16 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
|
353
358
|
const progress = stageProgress(stage);
|
|
354
359
|
main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
355
360
|
'<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of 5</p><h2>' + esc(stage.title) + '</h2><p>' + esc(stage.summary) + '</p></div>' + stageProgressCard(progress) + '</section>' +
|
|
356
|
-
(stage.id === "policies" ? renderPolicyApprovalGuidance() : "") + renderStagePageIndex(stage) + (stage.id === "controls" ? renderPolicyActivationAssessments() + renderEvidenceReadiness() : "") + '</div>';
|
|
361
|
+
(stage.id === "policies" ? renderPolicyApprovalGuidance() : "") + renderStagePageIndex(stage) + (stage.id === "controls" ? renderDocumentActivationAssessments() + renderPolicyActivationAssessments() + renderEvidenceReadiness() : "") + (stage.id === "audit" ? renderAuditDocumentActivationAssessments() : "") + '</div>';
|
|
357
362
|
main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
|
|
358
363
|
main.querySelectorAll("[data-evidence-family-extra]").forEach((card) => { card.hidden = false; });
|
|
359
364
|
event.currentTarget.remove();
|
|
360
365
|
});
|
|
361
366
|
main.querySelector("[data-review-policy-activation]")?.addEventListener("click", openPolicyActivationDialog);
|
|
367
|
+
main.querySelector("[data-review-document-activation]")?.addEventListener("click", () => openDocumentActivationDialog());
|
|
368
|
+
main.querySelector("[data-review-audit-document-activation]")?.addEventListener("click", (event) => {
|
|
369
|
+
openDocumentActivationDialog(event.currentTarget.dataset.reviewAuditDocumentActivation);
|
|
370
|
+
});
|
|
362
371
|
}
|
|
363
372
|
|
|
364
373
|
function renderPolicyApprovalGuidance() {
|
|
@@ -370,7 +379,107 @@ function renderPolicyApprovalGuidance() {
|
|
|
370
379
|
: "";
|
|
371
380
|
return '<article><strong>' + esc(proposal.title) + '</strong><p>' + esc(proposal.message) + '</p>' + review + '<div class="evidence-map-references">' + proposal.policyIds.map((id) => formatReference(id)).join("") + '</div></article>';
|
|
372
381
|
}).join("");
|
|
373
|
-
return '<section class="policy-lifecycle-note panel ' + (proposalRows ? "" : "single") + '"><div><p class="kicker">
|
|
382
|
+
return '<section class="policy-lifecycle-note panel ' + (proposalRows ? "" : "single") + '"><div><p class="kicker">Step 2 approval</p><h3>Approve the requirements and intended values</h3><p>Approval means your company reviewed and accepted the Policy requirements and the intended values in each required governed plan or schedule. It does not mean the linked Controls are implemented yet.</p><p>Bind each approval to the exact Markdown revision here. Build the Controls in Step 3, activate the unchanged governed Documents with a separate date and revision, then activate the Policies.</p></div>' + (proposalRows ? '<div class="policy-library-proposals">' + proposalRows + '</div>' : "") + '</section>';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function renderDocumentActivationAssessments() {
|
|
386
|
+
const assessments = state.programReadiness?.documentActivations || [];
|
|
387
|
+
if (!assessments.length) return "";
|
|
388
|
+
const candidates = assessments.filter(({ state }) => state === "ready-to-activate");
|
|
389
|
+
const cards = assessments.map((assessment) => {
|
|
390
|
+
const operating = assessment.state === "active-and-operating";
|
|
391
|
+
const controls = assessment.missingImplementationControlIds.length
|
|
392
|
+
? '<div class="policy-activation-gaps"><div><small>Controls still being implemented</small><div class="evidence-map-references">' + assessment.missingImplementationControlIds.map((id) => formatReference(id, "control")).join("") + '</div></div></div>'
|
|
393
|
+
: "";
|
|
394
|
+
return '<article class="policy-activation-card ' + esc(assessment.state) + '"><div class="evidence-map-card-head"><div><span class="badge ' + (operating ? "good" : "warn") + '">' + esc(assessment.label) + '</span><h3><a href="#/resource/document/' + encodeURIComponent(assessment.documentId) + '?stage=controls&documentScope=program">' + esc(assessment.title) + '</a></h3></div><small>' + assessment.gapCount + ' ' + pluralize("gap", assessment.gapCount) + '</small></div>' + controls + '<div class="policy-activation-actions"><a class="button" href="#/resource/document/' + encodeURIComponent(assessment.documentId) + '?stage=controls&documentScope=program">View Document</a></div></article>';
|
|
395
|
+
}).join("");
|
|
396
|
+
const action = candidates.length && !state.readOnly
|
|
397
|
+
? '<div class="evidence-map-actions"><button class="button primary" type="button" data-review-document-activation>Review Document activation</button></div>'
|
|
398
|
+
: "";
|
|
399
|
+
return '<section class="policy-activation"><div class="evidence-map-head"><div><p class="kicker">Governed Document cutover</p><h2>Activate the approved plans and schedules</h2><p>After the linked requirements are implemented, activate each unchanged approved Document. FileGRC records a separate activation date and binds the exact activated revision.</p></div>' + action + '</div><div class="policy-activation-grid">' + cards + '</div></section>';
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function renderAuditDocumentActivationAssessments() {
|
|
403
|
+
const audits = resourcesOfType("audit").map(({ record }) => record);
|
|
404
|
+
const audit = audits.find(({ status }) => !["complete", "closed", "canceled"].includes(status)) || audits[0];
|
|
405
|
+
const preparation = audit ? state.auditPreparations?.[audit.id] : null;
|
|
406
|
+
const assessments = preparation?.documentActivations || [];
|
|
407
|
+
if (!audit || !assessments.length) return "";
|
|
408
|
+
const candidates = assessments.filter(({ state }) => state === "ready-to-activate");
|
|
409
|
+
const cards = assessments.map((assessment) => {
|
|
410
|
+
const operating = assessment.state === "active-and-operating";
|
|
411
|
+
const issues = assessment.issues.length
|
|
412
|
+
? '<ul class="policy-activation-warning">' + assessment.issues.map((issue) => '<li>' + esc(issue) + '</li>').join("") + '</ul>'
|
|
413
|
+
: "";
|
|
414
|
+
return '<article class="policy-activation-card ' + esc(assessment.state) + '"><div class="evidence-map-card-head"><div><span class="badge ' + (operating ? "good" : "warn") + '">' + esc(assessment.label) + '</span><h3><a href="#/resource/document/' + encodeURIComponent(assessment.documentId) + '?stage=audit&documentScope=audit">' + esc(assessment.title) + '</a></h3></div><small>' + assessment.gapCount + ' ' + pluralize("gap", assessment.gapCount) + '</small></div>' + issues + '<div class="policy-activation-actions"><a class="button" href="#/resource/document/' + encodeURIComponent(assessment.documentId) + '?stage=audit&documentScope=audit">View Document</a></div></article>';
|
|
415
|
+
}).join("");
|
|
416
|
+
const action = candidates.length && !state.readOnly
|
|
417
|
+
? '<div class="evidence-map-actions"><button class="button primary" type="button" data-review-audit-document-activation="' + esc(audit.id) + '">Review Step 5 activation</button></div>'
|
|
418
|
+
: "";
|
|
419
|
+
return '<section class="policy-activation audit-document-activation"><div class="evidence-map-head"><div><p class="kicker">Step 5 Document lifecycle</p><h2>Activate the engagement Documents</h2><p>After each engagement Document is complete and independently approved, record the Person who activates the unchanged approved revision for this Audit.</p></div>' + action + '</div><div class="policy-activation-grid">' + cards + '</div></section>';
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function openDocumentActivationDialog(auditId = null) {
|
|
423
|
+
const candidates = (auditId
|
|
424
|
+
? state.auditPreparations?.[auditId]?.documentActivations || []
|
|
425
|
+
: state.programReadiness?.documentActivations || []).filter(({ state }) => state === "ready-to-activate");
|
|
426
|
+
const entryById = new Map(state.resources
|
|
427
|
+
.filter(({ record }) => record.type === "document" && record.status === "approved")
|
|
428
|
+
.map((entry) => [entry.record.id, entry]));
|
|
429
|
+
const ready = candidates.filter(({ documentId }) => entryById.has(documentId));
|
|
430
|
+
if (!ready.length) return;
|
|
431
|
+
const activators = state.resources
|
|
432
|
+
.filter(({ record }) => record.type === "person" && record.status === "active")
|
|
433
|
+
.map(({ record }) => record);
|
|
434
|
+
const today = currentDate();
|
|
435
|
+
const dialog = document.createElement("dialog");
|
|
436
|
+
dialog.className = "commit-dialog event-dialog policy-activation-dialog";
|
|
437
|
+
dialog.setAttribute("aria-labelledby", "document-activation-dialog-title");
|
|
438
|
+
const step = auditId ? "Step 5" : "Step 3";
|
|
439
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + step + ' activation</p><h2 id="document-activation-dialog-title">Review governed Document activation</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div>' +
|
|
440
|
+
'<p>' + (auditId ? 'These engagement Documents are complete and independently approved for the selected Audit.' : 'These Documents already have independent Step 2 approvals and implemented linked requirements.') + ' Activation records the Person who puts each unchanged approved revision into use.</p>' +
|
|
441
|
+
'<div class="policy-activation-selections">' + ready.map((assessment) => '<label class="policy-activation-selection"><input type="checkbox" name="documentId" value="' + esc(assessment.documentId) + '" checked><span><strong>' + esc(assessment.title) + '</strong><small>Approved ' + esc(assessment.approvedOn) + (auditId ? ' · Engagement facts complete' : ' · All linked Controls implemented') + '</small></span></label>').join("") + '</div>' +
|
|
442
|
+
'<label><span>Activated by</span><select name="activatedById" required><option value="">Select the Person who performs this activation</option>' + activators.map((person) => '<option value="' + esc(person.id) + '">' + esc(person.title) + '</option>').join("") + '</select></label>' +
|
|
443
|
+
'<label><span>Activation date</span><input name="activatedOn" type="date" value="' + esc(today) + '" readonly required></label>' +
|
|
444
|
+
'<label><span>Effective date</span><input name="effectiveOn" type="date" min="' + esc(today) + '" value="' + esc(today) + '" required></label>' +
|
|
445
|
+
'<div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary">Activate selected Documents</button></div></form>';
|
|
446
|
+
document.body.append(dialog);
|
|
447
|
+
const close = () => dialog.close();
|
|
448
|
+
dialog.querySelector(".icon-button").addEventListener("click", close);
|
|
449
|
+
dialog.querySelector('[data-event="cancel"]').addEventListener("click", close);
|
|
450
|
+
dialog.addEventListener("close", () => dialog.remove(), { once: true });
|
|
451
|
+
dialog.querySelector("form").addEventListener("submit", async (event) => {
|
|
452
|
+
event.preventDefault();
|
|
453
|
+
if (!event.currentTarget.reportValidity() || dialog.dataset.mutationBusy === "true") return;
|
|
454
|
+
const documentIds = [...event.currentTarget.querySelectorAll('[name="documentId"]:checked')].map(({ value }) => value);
|
|
455
|
+
if (!documentIds.length) {
|
|
456
|
+
dialog.querySelector(".dialog-error").textContent = "Select at least one approved Document to activate.";
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
setMutationBusy(dialog, true, "Activating…", "Activate selected Documents");
|
|
460
|
+
try {
|
|
461
|
+
const response = await localFetch("/api/document-activations", {
|
|
462
|
+
method: "POST",
|
|
463
|
+
headers: { "content-type": "application/json" },
|
|
464
|
+
body: JSON.stringify({
|
|
465
|
+
documentIds,
|
|
466
|
+
...(auditId ? { auditId } : {}),
|
|
467
|
+
activatedByIds: [event.currentTarget.elements.activatedById.value],
|
|
468
|
+
activatedOn: event.currentTarget.elements.activatedOn.value,
|
|
469
|
+
effectiveOn: event.currentTarget.elements.effectiveOn.value,
|
|
470
|
+
expectedRevisions: Object.fromEntries(documentIds.map((documentId) => [documentId, entryById.get(documentId).revision]))
|
|
471
|
+
})
|
|
472
|
+
});
|
|
473
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
474
|
+
applyMutationState(await response.json());
|
|
475
|
+
dialog.close();
|
|
476
|
+
render();
|
|
477
|
+
} catch (error) {
|
|
478
|
+
setMutationBusy(dialog, false, "", "Activate selected Documents");
|
|
479
|
+
dialog.querySelector(".dialog-error").textContent = error.message;
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
dialog.showModal();
|
|
374
483
|
}
|
|
375
484
|
|
|
376
485
|
function renderPolicyActivationAssessments() {
|
|
@@ -391,6 +500,7 @@ function renderPolicyActivationAssessments() {
|
|
|
391
500
|
gapGroup("Missing active Components", assessment.missingComponentControlIds, "control") +
|
|
392
501
|
gapGroup("Missing ready evidence sources", assessment.missingEvidenceSourceControlIds, "control") +
|
|
393
502
|
gapGroup("Missing enabled schedules", assessment.missingScheduleControlIds, "control") +
|
|
503
|
+
gapGroup("Inactive governed Documents", assessment.missingGovernedDocumentIds || [], "document") +
|
|
394
504
|
gapGroup("Unresolved Exceptions", assessment.unresolvedExceptionIds, "exception") +
|
|
395
505
|
gapGroup("Documented Exceptions", assessment.documentedExceptionIds, "exception") + '</div>' +
|
|
396
506
|
(assessment.activationWarning ? '<p class="policy-activation-warning">' + esc(assessment.activationWarning) + '</p>' : "") +
|
|
@@ -418,6 +528,7 @@ function openPolicyActivationDialog() {
|
|
|
418
528
|
[assessment.missingComponentControlIds.length, "missing Components"],
|
|
419
529
|
[assessment.missingEvidenceSourceControlIds.length, "missing evidence sources"],
|
|
420
530
|
[assessment.missingScheduleControlIds.length, "missing schedules"],
|
|
531
|
+
[(assessment.missingGovernedDocumentIds || []).length, "inactive governed Documents"],
|
|
421
532
|
[assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
|
|
422
533
|
].filter(([count]) => count).map(([count, label]) => count + " " + label).join(" · ");
|
|
423
534
|
return '<label class="policy-activation-selection"><input type="checkbox" name="policyId" value="' + esc(assessment.policyId) + '" checked><span><strong>' + esc(assessment.title) + '</strong><small>' + esc(assessment.label) + (details ? " · " + details : " · No implementation gaps") + '</small></span></label>';
|
|
@@ -426,7 +537,7 @@ function openPolicyActivationDialog() {
|
|
|
426
537
|
dialog.className = "commit-dialog event-dialog policy-activation-dialog";
|
|
427
538
|
dialog.setAttribute("aria-labelledby", "policy-activation-dialog-title");
|
|
428
539
|
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Step 3 cutover</p><h2 id="policy-activation-dialog-title">Review policy activation</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div>' +
|
|
429
|
-
'<p>Choose which approved Policies should take effect. Enabled schedules stay dormant until the effective date
|
|
540
|
+
'<p>Choose which approved Policies should take effect. Enabled schedules stay dormant until the effective date, their linked Controls are implemented, and required governed Documents are active and effective.</p>' +
|
|
430
541
|
'<div class="policy-activation-selections">' + candidateRows + '</div>' +
|
|
431
542
|
'<section class="policy-activation-review-warning"><strong>Activation with gaps</strong><p>You can activate a Policy with documented gaps or Exceptions. The gaps stay open, Controls keep their current status, and Evidence Readiness stays incomplete.</p></section>' +
|
|
432
543
|
'<label><span>Effective date</span><input name="effectiveOn" type="date" min="' + esc(today) + '" value="' + esc(today) + '" required></label>' +
|
|
@@ -593,7 +704,7 @@ function openCollectionReviewDialog(type) {
|
|
|
593
704
|
if (!assessment) return;
|
|
594
705
|
const configuration = assessment.configuration;
|
|
595
706
|
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
596
|
-
const v4 =
|
|
707
|
+
const v4 = modelSupports("program-scope");
|
|
597
708
|
const sourceType = v4 ? "component" : "system";
|
|
598
709
|
const systems = resourcesOfType(sourceType).filter(({ record }) => record.status === "active");
|
|
599
710
|
const allowedDecisions = configuration.decisions || ["complete"];
|
|
@@ -830,6 +941,9 @@ function stagePageDestinations(stage) {
|
|
|
830
941
|
}
|
|
831
942
|
|
|
832
943
|
function stagePageSummary(destination) {
|
|
944
|
+
if (destination.section && !destination.section.types.includes(destination.type)) {
|
|
945
|
+
return destination.description || destination.section.description || "";
|
|
946
|
+
}
|
|
833
947
|
const summaryKey = destination.type || "utility:" + destination.utility;
|
|
834
948
|
const section = destination.section || (destination.type
|
|
835
949
|
? readinessStageForType(destination.type)?.sections.find((candidate) => candidate.types.includes(destination.type))
|
|
@@ -1032,6 +1146,9 @@ function sectionDestinations(section) {
|
|
|
1032
1146
|
if (!definition) continue;
|
|
1033
1147
|
destinations.push({ type, kind: "Record page", label: titleCase(definition.pluralTitle), href: "#/resources/" + encodeURIComponent(type), description: definition.description });
|
|
1034
1148
|
}
|
|
1149
|
+
for (const link of section.relatedLinks || []) {
|
|
1150
|
+
destinations.push({ type: link.type, kind: "Record page", label: link.label, href: link.href, description: section.description });
|
|
1151
|
+
}
|
|
1035
1152
|
if (section.utility === "obligation-board") destinations.push({ utility: section.utility, kind: "Working page", label: "Work Queue", href: "#/stage/run", description: "Complete recurring work, Policy Event tasks, and assigned follow-up with its due windows and linked proof." });
|
|
1036
1153
|
if (section.utility === "audit-packet") destinations.push({ utility: section.utility, kind: "Working page", label: "Audit Evidence & Packet", href: "#/audit-packet", description: "Review filegrc Evidence and Evidence Artifacts, prepare fieldwork, and build the indexed packet." });
|
|
1037
1154
|
return destinations;
|
|
@@ -1590,7 +1707,7 @@ function openApplicabilityReviewDialog(type, entries) {
|
|
|
1590
1707
|
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
1591
1708
|
const reviewedRequirements = reviewedRequirementIds();
|
|
1592
1709
|
const pending = entries.filter(({ record }) => (
|
|
1593
|
-
type === "requirement" &&
|
|
1710
|
+
type === "requirement" && modelSupports("program-scope")
|
|
1594
1711
|
? !reviewedRequirements.has(record.id)
|
|
1595
1712
|
: !record.applicabilityReview
|
|
1596
1713
|
|| type === "requirement" && record.applicability === "undetermined"
|
|
@@ -1662,7 +1779,7 @@ function openApplicabilityReviewDialog(type, entries) {
|
|
|
1662
1779
|
reviewedOn: form.elements.reviewedOn.value,
|
|
1663
1780
|
expectedRevisions: Object.fromEntries([
|
|
1664
1781
|
...entries.filter((entry) => decisionIds.has(entry.record.id)).map((entry) => [entry.record.id, entry.revision]),
|
|
1665
|
-
...(type === "requirement" &&
|
|
1782
|
+
...(type === "requirement" && modelSupports("program-scope")
|
|
1666
1783
|
? state.resources.filter(({ record }) => record.id === activeProgram().id).map((entry) => [entry.record.id, entry.revision])
|
|
1667
1784
|
: [])
|
|
1668
1785
|
])
|
|
@@ -1910,8 +2027,15 @@ function eventStepSummary(step) {
|
|
|
1910
2027
|
function renderList(main, type, params = new URLSearchParams()) {
|
|
1911
2028
|
const definition = state.model.resources[type];
|
|
1912
2029
|
if (!definition) return renderNotFound(main);
|
|
1913
|
-
const listStage = readinessStageForType(type);
|
|
1914
|
-
const
|
|
2030
|
+
const listStage = READINESS_STAGES.find(({ id }) => id === params.get("stage")) || readinessStageForType(type);
|
|
2031
|
+
const documentScope = type === "document" ? params.get("documentScope") : null;
|
|
2032
|
+
const listTitle = type === "document" ? documentListTitle(params) : definition.pluralTitle;
|
|
2033
|
+
const entries = resourcesOfType(type).filter(({ record }) => (
|
|
2034
|
+
!documentScope || (documentScope === "audit") === auditSpecificDocument(record)
|
|
2035
|
+
));
|
|
2036
|
+
const detailContext = params.get("stage")
|
|
2037
|
+
? "?stage=" + encodeURIComponent(params.get("stage")) + (documentScope ? "&documentScope=" + encodeURIComponent(documentScope) : "")
|
|
2038
|
+
: "";
|
|
1915
2039
|
const requestedPage = Number(params.get("page"));
|
|
1916
2040
|
let pageNumber = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
|
1917
2041
|
const fields = [...new Set([
|
|
@@ -1929,7 +2053,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
1929
2053
|
const createButton = !state.readOnly && !definition.singleton && !collectionNeedsFirstRecord(type) && resourceCreationAllowed(type) ? '<button class="button primary" id="new-resource">New ' + esc(definition.title.toLowerCase()) + '</button>' : "";
|
|
1930
2054
|
const reviewedRequirements = reviewedRequirementIds();
|
|
1931
2055
|
const hasPendingApplicability = entries.some(({ record }) => (
|
|
1932
|
-
type === "requirement" &&
|
|
2056
|
+
type === "requirement" && modelSupports("program-scope")
|
|
1933
2057
|
? !reviewedRequirements.has(record.id)
|
|
1934
2058
|
: !record.applicabilityReview || type === "requirement" && record.applicability === "undetermined"
|
|
1935
2059
|
));
|
|
@@ -1941,7 +2065,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
1941
2065
|
const guideTrigger = '<button class="guide-trigger" id="resource-guide-trigger" type="button" aria-label="About ' + esc(definition.pluralTitle) + '" aria-haspopup="dialog" aria-controls="resource-guide" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8"></circle><path d="M7.8 7.5a2.4 2.4 0 1 1 3.25 2.25c-.7.31-1.05.72-1.05 1.5v.25M10 14.5v.1"></path></svg></button>';
|
|
1942
2066
|
const listTools = '<div class="list-tools list-header-tools"><label><span class="sr-only">Filter list</span><input id="list-search" type="search" placeholder="Filter ' + esc(definition.pluralTitle.toLowerCase()) + '"></label>' +
|
|
1943
2067
|
filters.map(({ name, label, values }) => '<select class="field-filter" data-field="' + esc(name) + '" aria-label="Filter by ' + esc(label.toLowerCase()) + '"><option value="">Any ' + esc(properCase(label)) + '</option>' + values.map((value) => '<option value="' + esc(value) + '">' + esc(filterOptionLabel(value)) + '</option>').join("") + '</select>').join("") + '<span id="result-count" aria-live="polite">' + entries.length + ' records</span>' + applicabilityButton + createButton + '</div>';
|
|
1944
|
-
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">' + esc(listStage?.title || groupTitle(definition.group)) + '</p><div class="page-title-line"><h2>' + esc(titleCase(
|
|
2068
|
+
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">' + esc(listStage?.title || groupTitle(definition.group)) + '</p><div class="page-title-line"><h2>' + esc(titleCase(listTitle)) + '</h2>' + guideTrigger + '</div></div>' + listTools + '</div>' + resourceGuide(type) +
|
|
1945
2069
|
collectionReviewPanel(type) +
|
|
1946
2070
|
'<section class="record-table-wrap"><table class="record-table"><thead><tr><th>' + esc(fieldLabel(type, "title")) + '</th>' + fields.map((name) => '<th>' + esc(fieldLabel(type, name)) + '</th>').join("") + '<th>Next action</th><th>Git file</th></tr></thead><tbody id="record-rows"></tbody></table></section>' +
|
|
1947
2071
|
'<nav class="pagination list-pagination" aria-label="' + esc(definition.pluralTitle) + ' pages" hidden><button class="button" type="button" data-page="previous">Previous</button><span class="page-status" aria-live="polite"></span><button class="button" type="button" data-page="next">Next</button></nav></div>';
|
|
@@ -1959,7 +2083,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
1959
2083
|
const start = (pageNumber - 1) * LIST_PAGE_SIZE;
|
|
1960
2084
|
const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
|
|
1961
2085
|
main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
|
|
1962
|
-
main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$operationTracking" ? controlOperationTracking(entry.record) : name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? displayStatus(entry.record) : entry.record[name], name, type, true)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : collectionEmptyState(type, definition)) + '</td></tr>';
|
|
2086
|
+
main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + detailContext + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$operationTracking" ? controlOperationTracking(entry.record) : name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? displayStatus(entry.record) : entry.record[name], name, type, true)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : collectionEmptyState(type, definition)) + '</td></tr>';
|
|
1963
2087
|
pagination.hidden = totalPages === 1;
|
|
1964
2088
|
previous.disabled = pageNumber === 1;
|
|
1965
2089
|
next.disabled = pageNumber === totalPages;
|
|
@@ -1971,6 +2095,8 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
1971
2095
|
main.querySelectorAll(".field-filter").forEach((select) => { select.value = params.get(select.dataset.field) || ""; });
|
|
1972
2096
|
const syncRoute = (mode = "replace") => {
|
|
1973
2097
|
const next = new URLSearchParams();
|
|
2098
|
+
if (params.get("stage")) next.set("stage", params.get("stage"));
|
|
2099
|
+
if (documentScope) next.set("documentScope", documentScope);
|
|
1974
2100
|
const query = main.querySelector("#list-search").value.trim();
|
|
1975
2101
|
if (query) next.set("q", query);
|
|
1976
2102
|
main.querySelectorAll(".field-filter").forEach((select) => { if (select.value) next.set(select.dataset.field, select.value); });
|
|
@@ -2010,6 +2136,22 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
2010
2136
|
}
|
|
2011
2137
|
}
|
|
2012
2138
|
|
|
2139
|
+
function auditSpecificDocument(record) {
|
|
2140
|
+
if (record.type !== "document") return false;
|
|
2141
|
+
if (modelSupports("document-workflow-scope")) return record.workflowScope === "engagement";
|
|
2142
|
+
const kinds = new Set([
|
|
2143
|
+
...(state.model.auditReadiness?.managementDocuments || []).map(({ kind }) => kind),
|
|
2144
|
+
"soc2-engagement-terms"
|
|
2145
|
+
]);
|
|
2146
|
+
return kinds.has(record.documentKind);
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
function documentListTitle(params = new URLSearchParams()) {
|
|
2150
|
+
if (params.get("documentScope") === "audit") return "Audit-specific Documents";
|
|
2151
|
+
if (params.get("documentScope") === "program") return "Governed Plans and Schedules";
|
|
2152
|
+
return "Documents";
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2013
2155
|
function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
2014
2156
|
const entry = resourcesOfType(type).find(({ record }) => record.id === id);
|
|
2015
2157
|
const definition = state.model.resources[type];
|
|
@@ -3393,12 +3535,15 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
|
|
|
3393
3535
|
}
|
|
3394
3536
|
if (field.type === "enum" || field.type === "rating" || field.type === "outcome") {
|
|
3395
3537
|
const values = field.values || (field.type === "rating" ? state.model.primitives.rating : state.model.primitives.outcome) || [];
|
|
3396
|
-
const
|
|
3538
|
+
const activationManagedType = ["policy", "document"].includes(type);
|
|
3539
|
+
const availableValues = activationManagedType && name === "status" && value !== "active"
|
|
3397
3540
|
? values.filter((item) => item !== "active")
|
|
3398
3541
|
: values;
|
|
3399
3542
|
control = '<select><option value="">Select</option>' + availableValues.map((item) => '<option value="' + esc(item) + '" ' + (value === item ? "selected" : "") + '>' + esc(properCase(item)) + '</option>').join("") + '</select>';
|
|
3400
|
-
const enumHelp =
|
|
3401
|
-
?
|
|
3543
|
+
const enumHelp = activationManagedType && name === "status"
|
|
3544
|
+
? type === "document"
|
|
3545
|
+
? "Approval ends at Approved. Activate program Documents from Step 3 after implementation, and engagement Documents from Step 5 after their Audit facts are complete."
|
|
3546
|
+
: "Step 2 ends at Approved. Activate approved Policies from the Step 3 Controls-page cutover."
|
|
3402
3547
|
: help;
|
|
3403
3548
|
return fieldWrap(name, "string", label, requiredMark, control, enumHelp, required);
|
|
3404
3549
|
}
|
package/src/workflow.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cp, mkdtemp, rm } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { modelSupports } from "../model/index.js";
|
|
4
5
|
import { assessRequiredAppointments } from "./appointments.js";
|
|
5
6
|
import { assessSourceCoverageReadiness } from "./source-coverage.js";
|
|
6
7
|
import { coverageEnd, coverageStart } from "./coverage.js";
|
|
@@ -93,7 +94,7 @@ async function assessWorkflowUnmeasured(input, options = {}) {
|
|
|
93
94
|
if (options.auditId && selectedAuditRecords[0]?.programId && selectedAuditRecords[0].programId !== programRecord.id) {
|
|
94
95
|
throw new Error(`Audit "${options.auditId}" belongs to Program "${selectedAuditRecords[0].programId}", not "${programRecord.id}".`);
|
|
95
96
|
}
|
|
96
|
-
const audits =
|
|
97
|
+
const audits = modelSupports(loaded.model, "program-scope")
|
|
97
98
|
? selectedAuditRecords.filter(({ programId }) => programId === programRecord.id)
|
|
98
99
|
: selectedAuditRecords;
|
|
99
100
|
const timezone = options.timezone || workspace?.timezone || "UTC";
|
|
@@ -633,7 +634,7 @@ function appointmentFinding(kind, template, record, state, requiredness) {
|
|
|
633
634
|
}
|
|
634
635
|
|
|
635
636
|
function sourceCoverageFindings(loaded, program) {
|
|
636
|
-
const selected =
|
|
637
|
+
const selected = modelSupports(loaded.model, "program-scope")
|
|
637
638
|
? new Set(program?.controlIds || [])
|
|
638
639
|
: null;
|
|
639
640
|
const selectedControlIds = loaded.resources
|
|
@@ -880,7 +881,7 @@ async function assessPeriodHealth(loaded, options) {
|
|
|
880
881
|
}
|
|
881
882
|
|
|
882
883
|
function auditLifecycleFindings(loaded, audits, program) {
|
|
883
|
-
if (!
|
|
884
|
+
if (!modelSupports(loaded.model, "guided-workflow")) return [];
|
|
884
885
|
const target = program || resolveProgram(loaded);
|
|
885
886
|
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
886
887
|
const findings = [];
|
|
@@ -939,7 +940,7 @@ function auditLifecycleFindings(loaded, audits, program) {
|
|
|
939
940
|
subsequentEventsIssue.message
|
|
940
941
|
));
|
|
941
942
|
}
|
|
942
|
-
const signatoryIssue =
|
|
943
|
+
const signatoryIssue = modelSupports(loaded.model, "program-scope") && lateStage
|
|
943
944
|
? signatoryAppointmentIssue(audit, byId)
|
|
944
945
|
: null;
|
|
945
946
|
if (signatoryIssue) {
|
|
@@ -1239,6 +1240,7 @@ function buildAssessments({ program, audits, auditPreparations, obligationPlan,
|
|
|
1239
1240
|
&& !findings.some((finding) => finding.assessment === "audit-closure" && blockingFinding(finding));
|
|
1240
1241
|
const deliveryFindingKeys = findingKeys(findings, "delivery-readiness");
|
|
1241
1242
|
const policyActivations = program.policyActivations || [];
|
|
1243
|
+
const documentActivations = program.documentActivations || [];
|
|
1242
1244
|
const policiesOperating = policyActivations.length > 0
|
|
1243
1245
|
&& policyActivations.every(({ state }) => state === "active-and-operating");
|
|
1244
1246
|
return {
|
|
@@ -1269,6 +1271,18 @@ function buildAssessments({ program, audits, auditPreparations, obligationPlan,
|
|
|
1269
1271
|
findingKeys: findingKeys(findings, "program-configuration", ({ key }) => key.includes(".policy-activation-")),
|
|
1270
1272
|
policies: policyActivations
|
|
1271
1273
|
},
|
|
1274
|
+
documentActivation: {
|
|
1275
|
+
status: documentActivations.length && documentActivations.every(({ state }) => state === "active-and-operating")
|
|
1276
|
+
? "complete"
|
|
1277
|
+
: documentActivations.length ? "needs-work" : "not-started",
|
|
1278
|
+
message: documentActivations.length && documentActivations.every(({ state }) => state === "active-and-operating")
|
|
1279
|
+
? "Every required governed Document is separately approved, activated, effective, and operating."
|
|
1280
|
+
: documentActivations.length
|
|
1281
|
+
? "Approve required plans and schedules in Step 2, implement their linked requirements, then activate their exact revisions in Step 3."
|
|
1282
|
+
: "No required governed Document activation is configured.",
|
|
1283
|
+
findingKeys: findingKeys(findings, "program-configuration", ({ key }) => key.includes(".document-")),
|
|
1284
|
+
documents: documentActivations
|
|
1285
|
+
},
|
|
1272
1286
|
policyLibraryReview: {
|
|
1273
1287
|
status: program.policyLibraryProposals?.length ? "review" : "current",
|
|
1274
1288
|
message: program.policyLibraryProposals?.length
|
package/src/workspace.js
CHANGED
|
@@ -2,8 +2,13 @@ import { readFile, readdir } from "node:fs/promises";
|
|
|
2
2
|
import { join, relative, sep } from "node:path";
|
|
3
3
|
import { loadModel } from "../model/index.js";
|
|
4
4
|
import { resolveWorkspaceRoot } from "./paths.js";
|
|
5
|
+
import { measureTiming } from "./timing.js";
|
|
5
6
|
|
|
6
7
|
export async function loadWorkspace(input = process.cwd()) {
|
|
8
|
+
return measureTiming("workspace-load", () => loadWorkspaceUnmeasured(input));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function loadWorkspaceUnmeasured(input) {
|
|
7
12
|
const root = resolveWorkspaceRoot(input);
|
|
8
13
|
const dataRoot = join(root, "data");
|
|
9
14
|
const diagnostics = [];
|