From a4198c27738f3e29552df268532993a5a063de83 Mon Sep 17 00:00:00 2001 From: Oliver Gibbs Date: Fri, 19 Jun 2026 09:10:44 +1000 Subject: [PATCH] Release: security remediation + workshop updates Squash of GitLab main since the initial release. Supersedes GitHub Dependabot PR #2 (react-router/react-router-dom -> 7.18.0, ahead of 7.17.0). - Dependency security upgrades (concurrently 10, aws-cdk-lib 2.260, dompurify 3.4.10, vite 6.4.3, uuid 13.0.2, js-yaml/minimatch overrides) clearing the open Dependabot advisories; no-patch items risk-accepted. - CodeQL: HTML sanitization hardening, CFN response log redaction, unbiased crypto.randomInt for temp passwords. - Post-release workshop updates (fabrication, progress UI, specs). --- arbiter/fabricator/index.py | 3 + arbiter/seedConfig/cfnresponse.py | 15 +- backend/package.json | 9 +- ...ealth-monitor-permissions.property.test.ts | 15 + .../__tests__/health-monitor.property.test.ts | 16 + .../integration-resolver-properties.test.ts | 10 + .../cognito-secret-handler/cfnresponse.py | 15 +- .../src/lambda/project-progress-updater.ts | 56 +- .../src/lambda/seed-admin-user/cfnresponse.py | 15 +- .../lambda/seed-organizations/cfnresponse.py | 15 +- .../update-email-templates/cfnresponse.py | 15 +- .../src/lambda/user-management-resolver.ts | 5 +- backend/src/utils/notifier-base.ts | 57 +- backend/src/utils/validation.ts | 28 +- frontend/package.json | 6 +- frontend/src/components/ProjectWorkspace.tsx | 9 +- package-lock.json | 25100 +++++++--------- package.json | 8 +- .../agent_intake_single/tools/fabricate.py | 4 +- 19 files changed, 11330 insertions(+), 14071 deletions(-) diff --git a/arbiter/fabricator/index.py b/arbiter/fabricator/index.py index 2eea04a..8b4a2e9 100644 --- a/arbiter/fabricator/index.py +++ b/arbiter/fabricator/index.py @@ -1692,6 +1692,9 @@ def store_agent_config_registry_bound( ) raise + # Fallback: ensure progress is published even if the LLM didn't call complete_task + publish_intake_progress(orchestration_id, agent_index, total_agents, agent_use_id) + def lambda_handler(event, context): print(f"processing event {event}") diff --git a/arbiter/seedConfig/cfnresponse.py b/arbiter/seedConfig/cfnresponse.py index 10bd62c..721d447 100644 --- a/arbiter/seedConfig/cfnresponse.py +++ b/arbiter/seedConfig/cfnresponse.py @@ -27,8 +27,19 @@ def send(event, context, responseStatus, responseData, physicalResourceId=None, json_responseBody = json.dumps(responseBody) - print("Response body:") - print(json_responseBody) + # Do not log the full response body: the 'Data' field may contain sensitive + # values (generated secrets, passwords, ARNs). Log only non-sensitive metadata. + print( + "Response status: {}; physicalResourceId={}; stackId={}; requestId={}; " + "logicalResourceId={}; dataKeys={}".format( + responseStatus, + responseBody['PhysicalResourceId'], + event['StackId'], + event['RequestId'], + event['LogicalResourceId'], + list(responseData.keys()) if isinstance(responseData, dict) else [], + ) + ) headers = { 'content-type': '', diff --git a/backend/package.json b/backend/package.json index 36f3fe1..2cf98e8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -48,7 +48,7 @@ "@aws-sdk/client-opensearchserverless": "^3.1007.0", "@aws-sdk/client-rds": "^3.1007.0", "@aws-sdk/client-redshift": "^3.1007.0", - "@aws-sdk/client-s3": "^3.400.0", + "@aws-sdk/client-s3": "^3.1070.0", "@aws-sdk/client-s3tables": "^3.1007.0", "@aws-sdk/client-sagemaker": "^3.1007.0", "@aws-sdk/client-secrets-manager": "^3.955.0", @@ -59,12 +59,12 @@ "@aws-sdk/client-timestream-write": "^3.1007.0", "@aws-sdk/credential-provider-node": "^3.400.0", "@aws-sdk/lib-dynamodb": "^3.400.0", - "@aws-sdk/s3-request-presigner": "^3.400.0", + "@aws-sdk/s3-request-presigner": "^3.1070.0", "@aws-sdk/signature-v4": "^3.300.0", "@databricks/sql": "^1.12.0", "@smithy/protocol-http": "^3.0.0", "ajv": "^6.14.0", - "aws-cdk-lib": "^2.100.0", + "aws-cdk-lib": "^2.260.0", "aws-lambda": "^1.0.7", "constructs": "^10.3.0", "jsonwebtoken": "^9.0.2", @@ -73,6 +73,7 @@ "uuid": "^9.0.0" }, "devDependencies": { + "@smithy/util-stream": "^4.5.25", "@types/aws-lambda": "^8.10.119", "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.2", @@ -80,7 +81,7 @@ "@types/uuid": "^9.0.4", "@typescript-eslint/eslint-plugin": "^6.4.0", "@typescript-eslint/parser": "^6.4.0", - "aws-cdk": "^2.1118.4", + "aws-cdk": "^2.1128.0", "aws-sdk-client-mock": "^4.1.0", "cdk-nag": "^2.38.1", "esbuild": "^0.25.11", diff --git a/backend/src/lambda/__tests__/health-monitor-permissions.property.test.ts b/backend/src/lambda/__tests__/health-monitor-permissions.property.test.ts index 025c03a..87a1a3b 100644 --- a/backend/src/lambda/__tests__/health-monitor-permissions.property.test.ts +++ b/backend/src/lambda/__tests__/health-monitor-permissions.property.test.ts @@ -9,8 +9,23 @@ */ import * as fc from 'fast-check'; +import { STSClient } from '@aws-sdk/client-sts'; +import { mockClient } from 'aws-sdk-client-mock'; import { processHealthChecks, HealthCheckResult } from '../health-monitor'; +// processHealthChecks assumes a scoped STS role per store (GetCallerIdentity + +// AssumeRole). With no real AWS credentials in the test environment, the +// un-mocked STS client hangs on credential resolution (IMDS) and retries, +// blowing past Jest's 30s timeout. Mock STS so those calls resolve instantly; +// the assumed credentials are irrelevant to the permission-error invariants +// under test (driven by the per-store testConnection mocks below). +const stsMock = mockClient(STSClient); + +beforeEach(() => { + stsMock.reset(); + stsMock.onAnyCommand().resolves({}); +}); + // ---- Generators ---- const dataStoreIdArb = fc.stringMatching(/^ds-[a-z0-9]{8}$/); diff --git a/backend/src/lambda/__tests__/health-monitor.property.test.ts b/backend/src/lambda/__tests__/health-monitor.property.test.ts index 2bedc1a..14f537a 100644 --- a/backend/src/lambda/__tests__/health-monitor.property.test.ts +++ b/backend/src/lambda/__tests__/health-monitor.property.test.ts @@ -2,10 +2,26 @@ // Validates: Requirements 6.3, 6.4, 10.4 import * as fc from 'fast-check'; +import { STSClient } from '@aws-sdk/client-sts'; +import { mockClient } from 'aws-sdk-client-mock'; // Import the testable health check logic (to be implemented) import { processHealthChecks, HealthCheckResult } from '../health-monitor'; +// processHealthChecks assumes a scoped STS role per store (GetCallerIdentity + +// AssumeRole). With no real AWS credentials in the test environment, the +// un-mocked STS client hangs on credential resolution (IMDS) and retries, +// which blows past Jest's 30s timeout and leaks open handles. Mock STS so +// those calls resolve instantly. The assumed credentials are irrelevant to +// the idempotency invariant under test — the per-store testConnection mocks +// (wired below) are what drive the property assertions. +const stsMock = mockClient(STSClient); + +beforeEach(() => { + stsMock.reset(); + stsMock.onAnyCommand().resolves({}); +}); + // ---- Generators ---- const dataStoreIdArb = fc.stringMatching(/^ds-[a-z0-9]{8}$/); diff --git a/backend/src/lambda/__tests__/integration-resolver-properties.test.ts b/backend/src/lambda/__tests__/integration-resolver-properties.test.ts index 02466f7..278e4a8 100644 --- a/backend/src/lambda/__tests__/integration-resolver-properties.test.ts +++ b/backend/src/lambda/__tests__/integration-resolver-properties.test.ts @@ -11,6 +11,7 @@ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; import { SSMClient } from '@aws-sdk/client-ssm'; import { BedrockAgentCoreControlClient } from '@aws-sdk/client-bedrock-agentcore-control'; +import { EventBridgeClient } from '@aws-sdk/client-eventbridge'; import { mockClient } from 'aws-sdk-client-mock'; // Mock AWS SDK clients @@ -18,6 +19,13 @@ const dynamoMock = mockClient(DynamoDBDocumentClient); const secretsMock = mockClient(SecretsManagerClient); const ssmMock = mockClient(SSMClient); const bedrockAgentMock = mockClient(BedrockAgentCoreControlClient); +// createIntegration / deleteIntegration emit EventBridge events via +// `eventBridge.send(PutEventsCommand)`. Left un-mocked, that real client hangs +// on credential resolution (IMDS) and retries, pushing this 100-run property +// over Jest's 30s timeout under full-suite worker contention. Mock it so the +// event handoff resolves instantly — no assertion in these properties inspects +// EventBridge, so the invariants are unchanged. +const eventBridgeMock = mockClient(EventBridgeClient); // Import the handler after mocking import { handler } from '../integration-resolver'; @@ -28,6 +36,8 @@ describe('Integration Resolver - Property-Based Tests', () => { secretsMock.reset(); ssmMock.reset(); bedrockAgentMock.reset(); + eventBridgeMock.reset(); + eventBridgeMock.onAnyCommand().resolves({}); // Set environment variables process.env.INTEGRATIONS_TABLE = 'test-integrations-table'; diff --git a/backend/src/lambda/cognito-secret-handler/cfnresponse.py b/backend/src/lambda/cognito-secret-handler/cfnresponse.py index 10bd62c..721d447 100644 --- a/backend/src/lambda/cognito-secret-handler/cfnresponse.py +++ b/backend/src/lambda/cognito-secret-handler/cfnresponse.py @@ -27,8 +27,19 @@ def send(event, context, responseStatus, responseData, physicalResourceId=None, json_responseBody = json.dumps(responseBody) - print("Response body:") - print(json_responseBody) + # Do not log the full response body: the 'Data' field may contain sensitive + # values (generated secrets, passwords, ARNs). Log only non-sensitive metadata. + print( + "Response status: {}; physicalResourceId={}; stackId={}; requestId={}; " + "logicalResourceId={}; dataKeys={}".format( + responseStatus, + responseBody['PhysicalResourceId'], + event['StackId'], + event['RequestId'], + event['LogicalResourceId'], + list(responseData.keys()) if isinstance(responseData, dict) else [], + ) + ) headers = { 'content-type': '', diff --git a/backend/src/lambda/project-progress-updater.ts b/backend/src/lambda/project-progress-updater.ts index 281cdc5..64226fa 100644 --- a/backend/src/lambda/project-progress-updater.ts +++ b/backend/src/lambda/project-progress-updater.ts @@ -19,33 +19,49 @@ export const handler = async (event: any) => { return; } + // Atomic monotonic update: only advance progress, never regress. + // This prevents concurrent fabrication events from overwriting each other. + const phaseKey = `progress.${phase}`; + + let currentPhase = 'CREATED'; + if (phase === 'implementation') currentPhase = completionPercentage === 100 ? 'IMPLEMENTATION_COMPLETE' : 'IMPLEMENTATION_IN_PROGRESS'; + else if (phase === 'planning') currentPhase = completionPercentage === 100 ? 'PLANNING_COMPLETE' : 'PLANNING_IN_PROGRESS'; + else if (phase === 'design') currentPhase = completionPercentage === 100 ? 'DESIGN_COMPLETE' : 'DESIGN_IN_PROGRESS'; + else if (phase === 'assessment') currentPhase = completionPercentage === 100 ? 'ASSESSMENT_COMPLETE' : 'ASSESSMENT_IN_PROGRESS'; + + try { + await client.send(new UpdateCommand({ + TableName: projectsTable, + Key: { id: sessionId }, + UpdateExpression: 'SET #phase = :pct, progress.currentPhase = :cp, updatedAt = :now', + ConditionExpression: 'attribute_not_exists(#phase) OR #phase < :pct', + ExpressionAttributeNames: { '#phase': phaseKey }, + ExpressionAttributeValues: { + ':pct': completionPercentage, + ':cp': currentPhase, + ':now': new Date().toISOString(), + }, + })); + } catch (err: any) { + if (err.name === 'ConditionalCheckFailedException') { + console.log(`Skipping stale progress: ${phase}=${completionPercentage}% (already higher)`); + return; + } + throw err; + } + + // Recompute overall from the now-updated record const getResult = await client.send(new GetCommand({ TableName: projectsTable, Key: { id: sessionId }, })); - - const current = getResult.Item?.progress || {}; - const assessment = phase === 'assessment' ? completionPercentage : (current.assessment || 0); - const design = phase === 'design' ? completionPercentage : (current.design || 0); - const planning = phase === 'planning' ? completionPercentage : (current.planning || 0); - const implementation = phase === 'implementation' ? completionPercentage : (current.implementation || 0); - - const overall = Math.round((assessment + design + planning + implementation) / 4); - - let currentPhase = 'CREATED'; - if (implementation > 0) currentPhase = implementation === 100 ? 'IMPLEMENTATION_COMPLETE' : 'IMPLEMENTATION_IN_PROGRESS'; - else if (planning > 0) currentPhase = planning === 100 ? 'PLANNING_COMPLETE' : 'PLANNING_IN_PROGRESS'; - else if (design > 0) currentPhase = design === 100 ? 'DESIGN_COMPLETE' : 'DESIGN_IN_PROGRESS'; - else if (assessment > 0) currentPhase = assessment === 100 ? 'ASSESSMENT_COMPLETE' : 'ASSESSMENT_IN_PROGRESS'; - + const p = getResult.Item?.progress || {}; + const overall = Math.round(((p.assessment || 0) + (p.design || 0) + (p.planning || 0) + (p.implementation || 0)) / 4); await client.send(new UpdateCommand({ TableName: projectsTable, Key: { id: sessionId }, - UpdateExpression: 'SET progress = :p, updatedAt = :now', - ExpressionAttributeValues: { - ':p': { overall, assessment, design, planning, implementation, currentPhase }, - ':now': new Date().toISOString(), - }, + UpdateExpression: 'SET progress.overall = :o', + ExpressionAttributeValues: { ':o': overall }, })); console.log(`Updated ${sessionId}: ${phase}=${completionPercentage}%, overall=${overall}%, currentPhase=${currentPhase}`); diff --git a/backend/src/lambda/seed-admin-user/cfnresponse.py b/backend/src/lambda/seed-admin-user/cfnresponse.py index 10bd62c..721d447 100644 --- a/backend/src/lambda/seed-admin-user/cfnresponse.py +++ b/backend/src/lambda/seed-admin-user/cfnresponse.py @@ -27,8 +27,19 @@ def send(event, context, responseStatus, responseData, physicalResourceId=None, json_responseBody = json.dumps(responseBody) - print("Response body:") - print(json_responseBody) + # Do not log the full response body: the 'Data' field may contain sensitive + # values (generated secrets, passwords, ARNs). Log only non-sensitive metadata. + print( + "Response status: {}; physicalResourceId={}; stackId={}; requestId={}; " + "logicalResourceId={}; dataKeys={}".format( + responseStatus, + responseBody['PhysicalResourceId'], + event['StackId'], + event['RequestId'], + event['LogicalResourceId'], + list(responseData.keys()) if isinstance(responseData, dict) else [], + ) + ) headers = { 'content-type': '', diff --git a/backend/src/lambda/seed-organizations/cfnresponse.py b/backend/src/lambda/seed-organizations/cfnresponse.py index 10bd62c..721d447 100644 --- a/backend/src/lambda/seed-organizations/cfnresponse.py +++ b/backend/src/lambda/seed-organizations/cfnresponse.py @@ -27,8 +27,19 @@ def send(event, context, responseStatus, responseData, physicalResourceId=None, json_responseBody = json.dumps(responseBody) - print("Response body:") - print(json_responseBody) + # Do not log the full response body: the 'Data' field may contain sensitive + # values (generated secrets, passwords, ARNs). Log only non-sensitive metadata. + print( + "Response status: {}; physicalResourceId={}; stackId={}; requestId={}; " + "logicalResourceId={}; dataKeys={}".format( + responseStatus, + responseBody['PhysicalResourceId'], + event['StackId'], + event['RequestId'], + event['LogicalResourceId'], + list(responseData.keys()) if isinstance(responseData, dict) else [], + ) + ) headers = { 'content-type': '', diff --git a/backend/src/lambda/update-email-templates/cfnresponse.py b/backend/src/lambda/update-email-templates/cfnresponse.py index 10bd62c..721d447 100644 --- a/backend/src/lambda/update-email-templates/cfnresponse.py +++ b/backend/src/lambda/update-email-templates/cfnresponse.py @@ -27,8 +27,19 @@ def send(event, context, responseStatus, responseData, physicalResourceId=None, json_responseBody = json.dumps(responseBody) - print("Response body:") - print(json_responseBody) + # Do not log the full response body: the 'Data' field may contain sensitive + # values (generated secrets, passwords, ARNs). Log only non-sensitive metadata. + print( + "Response status: {}; physicalResourceId={}; stackId={}; requestId={}; " + "logicalResourceId={}; dataKeys={}".format( + responseStatus, + responseBody['PhysicalResourceId'], + event['StackId'], + event['RequestId'], + event['LogicalResourceId'], + list(responseData.keys()) if isinstance(responseData, dict) else [], + ) + ) headers = { 'content-type': '', diff --git a/backend/src/lambda/user-management-resolver.ts b/backend/src/lambda/user-management-resolver.ts index bd8cd97..cfc706f 100644 --- a/backend/src/lambda/user-management-resolver.ts +++ b/backend/src/lambda/user-management-resolver.ts @@ -570,7 +570,6 @@ async function adminResendInvitation(event: any, userId: string) { function generateTemporaryPassword(): string { const length = 12; const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'; - const randomBytes = crypto.randomBytes(length); let password = ''; // Ensure password meets Cognito requirements @@ -580,13 +579,13 @@ function generateTemporaryPassword(): string { password += '!'; // symbol for (let i = password.length; i < length; i++) { - password += charset.charAt(randomBytes[i] % charset.length); + password += charset.charAt(crypto.randomInt(charset.length)); } // Shuffle the password using crypto-secure randomness const arr = password.split(''); for (let i = arr.length - 1; i > 0; i--) { - const j = crypto.randomBytes(1)[0] % (i + 1); + const j = crypto.randomInt(i + 1); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr.join(''); diff --git a/backend/src/utils/notifier-base.ts b/backend/src/utils/notifier-base.ts index fd11b72..930e996 100644 --- a/backend/src/utils/notifier-base.ts +++ b/backend/src/utils/notifier-base.ts @@ -121,29 +121,46 @@ export type DetailPayloadOf = GovernancePayloadM // Fail-closed sanitiser — strips tags (and their content) for the three most // dangerous HTML embed vectors. Applied recursively across string fields only. -const SCRIPT_RE = /]*>[\s\S]*?<\/script\s*>/gi; -const IFRAME_RE = /]*>[\s\S]*?<\/iframe\s*>/gi; -const OBJECT_RE = /]*>[\s\S]*?<\/object\s*>/gi; -// Also strip self-closing / un-closed opening tags so ``), and stray / +// unterminated openers are removed, so a malformed tag cannot bypass the +// filter. +// * `[\s\S]` is used for tag bodies/content so newlines cannot hide a tag. +const DANGEROUS_TAGS = 'script|iframe|object'; +// Balanced block (content included). Backreference keeps +// the open/close tag names matched. +const PAIRED_TAG_RE = new RegExp( + `<\\s*(${DANGEROUS_TAGS})\\b[^>]*>[\\s\\S]*?<\\s*\\/\\s*\\1\\b[^>]*>`, + 'gi' +); +// Any leftover opening or closing tag for the dangerous set, including an +// unterminated opener at end-of-input (trailing `>` optional). +const STRAY_TAG_RE = new RegExp( + `<\\s*\\/?\\s*(?:${DANGEROUS_TAGS})\\b[^>]*>?`, + 'gi' +); function sanitizeString(s: string): string { let out = s; - out = out.replace(SCRIPT_RE, ''); - out = out.replace(IFRAME_RE, ''); - out = out.replace(OBJECT_RE, ''); - out = out.replace(SCRIPT_OPEN_RE, ''); - out = out.replace(IFRAME_OPEN_RE, ''); - out = out.replace(OBJECT_OPEN_RE, ''); - out = out.replace(SCRIPT_CLOSE_RE, ''); - out = out.replace(IFRAME_CLOSE_RE, ''); - out = out.replace(OBJECT_CLOSE_RE, ''); + let previous: string; + do { + previous = out; + out = out.replace(PAIRED_TAG_RE, '').replace(STRAY_TAG_RE, ''); + } while (out !== previous); return out; } diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 204b594..6a88f29 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -55,13 +55,27 @@ export function sanitizeString(input: string, maxLength: number = 1000): string return ''; } - // Remove potentially dangerous characters - const sanitized = input - .replace(/)<[^<]*)*<\/script>/gi, '') - .replace(/<[^>]*>/g, '') - .trim(); - - return sanitized.substring(0, maxLength); + // Strip dangerous markup, producing plain text. Two passes are each applied + // repeatedly to a fixed point so a replacement cannot re-introduce a stripped + // construct via nesting/overlap (e.g. "ipt>") — this is the + // CodeQL js/incomplete-multi-character-sanitization remediation: + // 1. Drop blocks together with their text content. The + // closing tag tolerates trailing junk ("") and the body + // uses [\s\S] so a newline or malformed end tag cannot bypass it + // (js/bad-tag-filter remediation). + // 2. Remove any remaining HTML tag. A bare "<" with no closing ">" is left + // intact, matching the prior behaviour (no benign text is dropped). + const SCRIPT_BLOCK_RE = /<\s*script\b[^>]*>[\s\S]*?<\s*\/\s*script\b[^>]*>/gi; + const HTML_TAG_RE = /<[^>]*>/g; + + let sanitized = input; + let previous: string; + do { + previous = sanitized; + sanitized = sanitized.replace(SCRIPT_BLOCK_RE, '').replace(HTML_TAG_RE, ''); + } while (sanitized !== previous); + + return sanitized.trim().substring(0, maxLength); } export function validatePaginationInput(input: any): void { diff --git a/frontend/package.json b/frontend/package.json index 33eff0c..216e284 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,7 +55,7 @@ "react-hook-form": "^7.55.0", "react-markdown": "^10.1.0", "react-resizable-panels": "^2.1.9", - "react-router-dom": "^7.17.0", + "react-router-dom": "^7.13.2", "reactflow": "^11.11.4", "recharts": "^2.15.2", "remark-gfm": "^4.0.1", @@ -63,7 +63,7 @@ "tailwind-merge": "*", "tailwindcss": "^4.1.17", "tw-animate-css": "^1.4.0", - "uuid": "^13.0.0", + "uuid": "^13.0.2", "vaul": "^1.1.2" }, "devDependencies": { @@ -83,7 +83,7 @@ "jest": "^29.6.2", "jest-environment-jsdom": "^29.7.0", "ts-jest": "^29.1.1", - "vite": "^6.4.2" + "vite": "^6.4.3" }, "scripts": { "dev": "vite", diff --git a/frontend/src/components/ProjectWorkspace.tsx b/frontend/src/components/ProjectWorkspace.tsx index aa1362f..355f539 100644 --- a/frontend/src/components/ProjectWorkspace.tsx +++ b/frontend/src/components/ProjectWorkspace.tsx @@ -55,6 +55,7 @@ const DOC_TABS: DocTab[] = [ { id: 'resourcing', label: 'Resourcing', documentKey: 'design/resourcing_report.md', progressKey: 'planning', planningOrder: 0 }, { id: 'business_plan', label: 'Business Plan', documentKey: 'planning/business_plan.md', progressKey: 'planning', planningOrder: 1 }, { id: 'commercial_plan', label: 'Commercial Plan', documentKey: 'planning/commercial_plan.md', progressKey: 'planning', planningOrder: 2 }, + { id: 'fabrication_plan', label: 'Fabrication Plan', documentKey: 'planning/fabrication_plan.md', progressKey: 'implementation' }, ]; const WELCOME_MESSAGE: Message = { @@ -106,10 +107,12 @@ function VersionPanel({ const [selected, setSelected] = useState(null); const [compareDoc, setCompareDoc] = useState(null); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { listDocumentVersions(projectId, documentKey) .then(setVersions) + .catch((e) => setError(e?.message || 'Failed to load versions')) .finally(() => setLoading(false)); }, [projectId, documentKey]); @@ -130,8 +133,10 @@ function VersionPanel({
{loading ? (

Loading...

+ ) : error ? ( +

{error}

) : versions.length === 0 ? ( -

No versions yet

+

No versions yet — edit a section to create a revision

) : versions.map((v) => (