diff --git a/BASELINE.md b/BASELINE.md index 059fa28..7bcf7ff 100644 --- a/BASELINE.md +++ b/BASELINE.md @@ -314,3 +314,32 @@ The management increment added guided create/replace/delete controls with masked Final pre-manual Slice 3 validation extracted durable tests for all four protected injection modes, missing/not-found/type-mismatch failures, recursive reflected-value redaction, and conservative URL sanitization. A Docker credential remained usable after backend restart with the same key and its reflected Authorization header was `[REDACTED]`; the disposable record and key were removed afterward. Consolidated validation passed at 22 frontend suites / 509 tests, backend security tests and TypeScript build, frontend production build, the 13-valid / 2-expected-invalid / 2-diagnostic-invalid schema matrix, Docker health, and `git diff --check`. Proportional manual acceptance remains the next gate. Proportional manual acceptance passed on 2026-08-01 in isolated Chromium using an unsaved browser copy of `Slice 2 Final` (`#12`). All four steps passed: masked lifecycle and metadata-only display; type-compatible choices and opaque-only canonical state; protected Preview execution with reflected credential redaction; and referenced deletion protection, cleanup, and missing-reference failure safety. The saved project was unchanged and the final server-side secret list was empty. The blocked-deletion warning remained visible after later successful cleanup; this was non-blocking and is tracked for stale-feedback cleanup in `ROADMAP.md`. The user granted explicit final Slice 3 sign-off on 2026-08-02; Slice 3 is complete. +## Slice 4 Proxy Security and Observability — 2026-08-02 + +The initial Slice 4 implementation added the documented public-destination/default-deny policy, exact-origin server-side internal exceptions, pinned DNS results, redirect revalidation, cross-origin credential stripping, header and resource controls, safe errors, persistent bounded SQLite execution history, filtering/clearing APIs, and the Execution History UI. + +Validation passed with `cd backend && npm test` (six compiled test files), `cd frontend && npm test -- --watchAll=false` (22 suites / 509 tests), `cd frontend && npm run build`, the 13-valid / 2-expected-invalid / 2-diagnostic-invalid schema matrix, Docker Compose rebuild/status/health, frontend HTTP, an approved public HTTPS execution, a blocked loopback execution, history retrieval across backend recreation, and history cleanup. The first public Docker request exposed and led to correction of Node's `lookup({all:true})` callback handling; the rebuilt repeat returned HTTP 200. Disposable history was cleared with HTTP 204. See `SLICE4.md` for exact command/result detail. Proportional manual workflows remain pending and are not implied by this automated baseline. + +Proportional manual verification subsequently passed all five workflows on 2026-08-02 after one failed redaction check and two initially unavailable DNS checks were remediated and repeated. Failure logs now exclude destination URLs and private addresses; backend automation passes seven compiled test files. A disposable Docker authoritative-DNS fixture proved mixed public/private answers are rejected and address pinning prevents a rebinding lookup between validation and connection. IPv6 literals now classify directly and return `PROXY_DESTINATION_FORBIDDEN`. Focused log searches returned zero private literal or raw transport-error matches. Cleanup restored empty secrets, history, secret-key configuration, internal-origin configuration, and removed all fixtures. At that checkpoint, explicit final Slice 4 sign-off was still pending. + +Independent re-test evidence supplied on 2026-08-02 confirmed the rebuilt deployment and all previously failed/unavailable checks pass, with proportional approved-destination, credential, response-binding, and canonical/runtime separation regressions. Exact searches found no disposable credentials, query markers, private literals, transport details, or stack traces in the tested surfaces. The re-test reported 18/18 backend tests passing, complete fixture/secret/history/configuration cleanup, healthy services, and a passing `git diff --check`. It made no source edits, commits, or pushes and explicitly did not grant final sign-off. + +The user granted explicit final Slice 4 sign-off on 2026-08-03 with the exact wording: “I grant explicit final Slice 4 sign-off.” Final consolidated validation passed: backend TypeScript build and 7 compiled test files, 22 frontend suites / 509 tests, frontend production build, the schema fixture matrix at 13 valid / 2 expected-invalid / 2 diagnostic-invalid, rebuilt Docker Compose services, backend health, frontend HTTP 200, empty execution history and secrets, empty secret-key/internal-origin configuration, and `git diff --check`. Slice 4 is complete. No commit or push was performed or authorized. + +## Slice 5 Validation and Error Handling — 2026-08-04 + +The implementation inventory found that JSON Apply used the schema endpoint while create, update, load, and database writes lacked an unconditional gate. Slice 5 now uses one backend schema and semantic validator at every boundary, accepts exactly schema version 0.1.0, rejects invalid stored documents without mutating them, and returns stable codes, JSON Pointer-style paths, severities, and messages. The persistence layer repeats validation immediately before insert/update, so rejected updates cannot partially change metadata or JSON. + +Semantic validation covers duplicate identities/names, dangling references, runtime-supported events/binding paths/triggers/targets, component-specific properties, variable defaults, templates, and default-page references. Compatibility-only inputMap, action.responseMapping, component binding references, and Binding.transform are non-blocking warnings and cannot be newly authored by guided UI. Canonical examples were reconciled, project settings received schema-matching TypeScript types, and path-parameter values now use the same component/variable interpolation contract as other request inputs before backend URL substitution. + +Automated validation passed: backend build and 8 compiled test files, frontend 23 suites / 512 tests, frontend production build, structural schema matrix at 13 valid / 2 expected-invalid / 2 diagnostic-invalid, and semantic validation of all 13 valid fixtures with no errors or warnings. Rebuilt Docker services were healthy and returned backend health plus frontend HTTP 200. A disposable valid project was created; an invalid update returned PROJECT_VALIDATION_FAILED with actionable paths; the subsequent GET proved the original name and JSON were unchanged; cleanup deleted the disposable record with HTTP 204. Manual acceptance remains the only Slice 5 gate. + +The first manual run found that 422 validation responses omitted `warnings`, causing JSON Editor to throw while rendering Tests 1B–1D. The route now returns the full validation result and the frontend normalizes absent arrays defensively. Consolidated remediation validation passed at 8 backend test files and 23 frontend suites / 513 tests plus the frontend production build. Docker was rebuilt, and direct API checks returned complete error and warning arrays for unsupported version, dangling action, and incompatible binding cases. Manual retest remains pending. + +The retest passed Tests 1B–1D. Public httpbingo then returned HTTP 402 during Test 3B, demonstrating that an external dependency could not provide deterministic acceptance evidence. A separate manual-test Compose override now runs the repository mock server and grants only its exact internal origin. Direct proxy checks passed for HTTP 200 success, a measured one-second delay, and a controlled HTTP 503 response. The normal Compose definition remains unchanged; cleanup removes the override and internal exception. + +Manual Tests 1–5 subsequently passed. Test 6A exposed that JSON Editor lacked the shared validation summary used by Visual Editor, Actions & Bindings, and Preview. The summary is now rendered in JSON Editor and has blocking-error and compatibility-warning regression coverage. Frontend validation passes at 24 suites / 515 tests plus production build. A complete structurally valid Test 6B fixture was also added and semantically validates with exactly one non-blocking `BINDING_TRANSFORM_DEFERRED` warning. + +The final manual continuation passed Test 6A and Test 6B; the user confirmed all Slice 5 manual tests pass. Cleanup removed seven execution records, disposable project `#14`, the mock container, and its exact internal-origin exception. Standard Compose was restored with backend health, frontend HTTP 200, empty execution history/secrets/configuration, and project `#14` absent. Explicit final Slice 5 sign-off remains the only gate. + +The user granted explicit final Slice 5 sign-off on 2026-08-04 with the wording: “Confirm slice5 signoff.” Slice 5 is complete. diff --git a/CODEX.md b/CODEX.md index e3e4be2..3ae073d 100644 --- a/CODEX.md +++ b/CODEX.md @@ -103,10 +103,10 @@ All eleven schema-supported MVP components are available in the palette and have - Basic, Bearer, and API-key authentication types are modeled but currently execute anonymously. - Secure credential storage and secret resolution are not implemented. -- The REST proxy still needs endpoint allowlisting, SSRF protection, and stricter header and URL validation. -- Sanitized execution history and troubleshooting views are not implemented. +- Slice 4 proxy enforcement and sanitized execution history are complete in the working tree. All proportional manual workflows and the independent rebuilt-Docker/isolated-Playwright re-test passed. The user granted explicit final sign-off on 2026-08-03 with the exact wording: “I grant explicit final Slice 4 sign-off.” - Visual REST action, Button event, request-input, response-binding, variable, and page onLoad authoring are implemented. Page-load manual acceptance passed on 2026-07-29. -- Project documents are validated in the JSON Editor, but save operations still need an unconditional validation gate. +- Project documents use one backend schema/semantic validator across JSON Apply, create, update, save, stored-project load checks, and shared frontend diagnostics. Invalid documents never reach a database write. +- Slice 5 validation and error handling passed consolidated automation, proportional manual acceptance, cleanup, and explicit final sign-off on 2026-08-04. - Backend and end-to-end automated test coverage remains incomplete. - IBM Bob/watsonx will not be used; AI is provider-neutral and post-MVP. - All five modeled authentication modes are required for MVP. @@ -136,7 +136,7 @@ Unless the user chooses a different priority, proceed in this order: 1. Continue Slice 3 with canonical secret references, action authoring, credential injection, compatibility validation, and redaction tests. 2. Add validation to every project save path. -3. Harden the REST proxy and add sanitized execution logging. +3. Continue release-critical backend, end-to-end, and security regression coverage from Slice 6. 4. Add backend, end-to-end, and security regression tests. 5. Reconcile and consolidate project documentation. diff --git a/ROADMAP.md b/ROADMAP.md index 250fd61..ecd26bb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,9 +19,9 @@ The slice files remain the detailed implementation plans. When an older list con | --- | --- | --- | --- | | [Slice 1](SLICE1.md) | MVP GUI components | Complete | None | | [Slice 2](SLICE2.md) | Visual configuration | Complete | None | -| [Slice 3](SLICE3.md) | Authentication and secrets | Not started | Entire slice | -| [Slice 4](SLICE4.md) | Proxy security and observability | Not started | Entire slice | -| [Slice 5](SLICE5.md) | Validation and error handling | Not started | Entire slice | +| [Slice 3](SLICE3.md) | Authentication and secrets | Complete | None | +| [Slice 4](SLICE4.md) | Proxy security and observability | Complete | None | +| [Slice 5](SLICE5.md) | Validation and error handling | Complete | None | | [Slice 6](SLICE6.md) | Testing and release validation | Partial | Release-critical coverage, end-to-end workflows, security regression, and final evidence | | [Slice 7](SLICE7.md) | MVP scope decision | Complete | Cross-document reconciliation is carried by Slices 6 and 8 | | [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain | @@ -126,23 +126,25 @@ OAuth 2.0, IBM Cloud IAM, mTLS, and arbitrary authentication scripts remain post ### Slice 4 — Destination and request policy -- [ ] Document the proxy threat model and default-deny boundaries. -- [ ] Define permitted schemes, origins, hosts, ports, and explicit internal-host exceptions. -- [ ] Block unsafe local, link-local, metadata-service, and unapproved destinations. -- [ ] Revalidate DNS results and redirect targets at every boundary. -- [ ] Permit only HTTP and HTTPS. -- [ ] Allowlist forwarded headers and strip dangerous or hop-by-hop headers. -- [ ] Add request-size, response-size, redirect, and execution-time limits. -- [ ] Standardize safe proxy errors and malformed-response behavior. +- [x] Document the proxy threat model and default-deny boundaries. +- [x] Define permitted schemes, origins, hosts, ports, and explicit internal-host exceptions. +- [x] Block unsafe local, link-local, metadata-service, and unapproved destinations. +- [x] Revalidate DNS results and redirect targets at every boundary. +- [x] Permit only HTTP and HTTPS. +- [x] Allowlist forwarded headers and strip dangerous or hop-by-hop headers. +- [x] Add request-size, response-size, redirect, and execution-time limits. +- [x] Standardize safe proxy errors and malformed-response behavior. ### Slice 4 — Sanitized execution history -- [ ] Persist timestamp, project/action identity, method, sanitized URL, status, duration, outcome, sanitized error, and bounded response-preview metadata. -- [ ] Mask authorization headers, passwords, tokens, API keys, cookies, credential-resolution details, and sensitive query values. -- [ ] Add an execution-history view with recent executions, action/status filtering, useful errors, and no backend stack traces. -- [ ] Decide retention, persistence, restart, and clear-history behavior. -- [ ] Add successful, failed, redirect, timeout, size-limit, SSRF, redaction, and regression tests. -- [ ] Verify proxy policy through Docker Compose. +- [x] Persist timestamp, project/action identity, method, sanitized URL, status, duration, outcome, sanitized error, and bounded response-preview metadata. +- [x] Mask authorization headers, passwords, tokens, API keys, cookies, credential-resolution details, and sensitive query values. +- [x] Add an execution-history view with recent executions, action/status filtering, useful errors, and no backend stack traces. +- [x] Decide retention, persistence, restart, and clear-history behavior. +- [x] Add successful, failed, redirect, timeout, size-limit, SSRF, redaction, and regression coverage, with socket-level scenarios retained in the separate proportional manual gate. +- [x] Verify proxy policy through Docker Compose. +- [x] Complete proportional manual policy, safe-error, execution-history, filtering, lifecycle, and redaction acceptance. +- [x] Obtain explicit final Slice 4 sign-off. Granted by the user on 2026-08-03 with the exact wording: “I grant explicit final Slice 4 sign-off.” Enterprise SIEM integration, RBAC, and enterprise audit retention remain post-MVP. @@ -150,32 +152,32 @@ Enterprise SIEM integration, RBAC, and enterprise audit retention remain post-MV ### Slice 5 — Canonical validation and persistence -- [ ] Inventory validation behavior across JSON Apply, create, update, load, save, and Preview. -- [ ] Centralize backend schema validation and use it at every persistence boundary. -- [ ] Reject invalid projects without partial database updates. -- [ ] Add supported schema-version and compatibility behavior. -- [ ] Decide how invalid stored JSON is handled and whether v0.1.0 needs migrations. -- [ ] Verify explicit `null`, missing values, empty arrays, and empty objects round-trip correctly. -- [ ] Confirm backend default projects are canonical and schema-valid. +- [x] Inventory validation behavior across JSON Apply, create, update, load, save, and Preview. +- [x] Centralize backend schema validation and use it at every persistence boundary. +- [x] Reject invalid projects without partial database updates. +- [x] Add supported schema-version and compatibility behavior. +- [x] Decide how invalid stored JSON is handled and whether v0.1.0 needs migrations. +- [x] Verify explicit `null`, missing values, empty arrays, and empty objects round-trip correctly. +- [x] Confirm backend default projects are canonical and schema-valid. ### Slice 5 — Semantic validation and diagnostics -- [ ] Validate duplicate IDs/names and dangling action, component, variable, event, and binding references. -- [ ] Validate component-specific properties and response-binding target compatibility. -- [ ] Standardize backend error codes, paths, severities, and response shapes. -- [ ] Surface actionable errors consistently in Visual Editor, Actions & Bindings, JSON Editor, Inspector, and Preview. -- [ ] Add Preview loading, empty, upstream-error, mapping-error, retry/recovery, and stale-error behavior for all supported targets. -- [ ] Preserve unsaved edits when validation or network operations fail. -- [ ] Clear stale Secrets lifecycle error feedback after a later successful create, replace, or delete operation. -- [ ] Reconcile schema descriptions and examples with canonical top-level `project.bindings`. -- [ ] Reconcile free-form binding triggers with the triggers the runtime actually supports. -- [ ] Unify or explicitly document the path-parameter interpolation contract. -- [ ] Prevent new uses of deprecated `action.responseMapping`, inert `ComponentEvent.inputMap`, and unused component-level binding shapes. -- [ ] Resolve, remove, document, or explicitly defer every modeled-but-unexecuted field, including `Binding.transform`. -- [ ] Strengthen `project.settings` TypeScript typing to match the schema. -- [ ] Decide whether backend-supported project deletion requires a v0.1.0 UI control. -- [ ] Review deletion confirmations, keyboard accessibility, component disabled/hidden behavior, and Inspector/runtime diagnostic agreement. -- [ ] Add malformed, semantic, version, rollback, and recovery regression tests. +- [x] Validate duplicate IDs/names and dangling action, component, variable, event, and binding references. +- [x] Validate component-specific properties and response-binding target compatibility. +- [x] Standardize backend error codes, paths, severities, and response shapes. +- [x] Surface actionable errors consistently in Visual Editor, Actions & Bindings, JSON Editor, Inspector, and Preview. +- [x] Add Preview loading, empty, upstream-error, mapping-error, retry/recovery, and stale-error behavior for all supported targets. +- [x] Preserve unsaved edits when validation or network operations fail. +- [x] Clear stale Secrets lifecycle error feedback after a later successful create, replace, or delete operation. +- [x] Reconcile schema descriptions and examples with canonical top-level `project.bindings`. +- [x] Reconcile free-form binding triggers with the triggers the runtime actually supports. +- [x] Unify and document the path-parameter interpolation contract. +- [x] Prevent new uses of deprecated `action.responseMapping`, inert `ComponentEvent.inputMap`, and unused component-level binding shapes. +- [x] Resolve, remove, document, or explicitly defer every modeled-but-unexecuted field, including `Binding.transform`. +- [x] Strengthen `project.settings` TypeScript typing to match the schema. +- [x] Decide whether backend-supported project deletion requires a v0.1.0 UI control. +- [x] Review deletion confirmations, keyboard accessibility, component disabled/hidden behavior, and Inspector/runtime diagnostic agreement. +- [x] Add malformed, semantic, version, rollback, and recovery regression tests. ## 6. Testing and Release Validation diff --git a/TESTING.md b/TESTING.md index b330ffe..0931426 100644 --- a/TESTING.md +++ b/TESTING.md @@ -26,6 +26,30 @@ tester, environment, and any observations in the result block for that workflow. | Final Slice 2 sign-off | Accepted 2026-07-31 | | Slice 2a request-reference usability | Complete and signed off | | Slice 3 authentication and secrets | Complete and signed off 2026-08-02 | +| Slice 4 proxy security and execution history | Complete and signed off 2026-08-03 | +| Slice 5 validation and error handling | Complete and signed off 2026-08-04 | + +## Slice 5 Manual Gate + +Slice 5 implementation and automated validation completed on 2026-08-04. The proportional manual gate is defined in `SLICE5.md` and covers rejected JSON/semantic defects, atomic save/load rollback, Preview failure and recovery states, unsaved-edit preservation, explicit-null and empty-container round trips, discard confirmations, and cross-view diagnostic agreement. Do not mark Slice 5 complete until those workflows pass and the user grants explicit final sign-off. + +The first manual run passed malformed-JSON Test 1A. Tests 1B–1D exposed a frontend crash caused by the invalid validation response omitting its `warnings` array. The backend contract and defensive frontend parser were corrected; backend automation, 23 frontend suites / 513 tests, production build, Docker rebuild, and direct checks of all three failed response shapes pass. Manual retest of 1B–1D is pending; Test 2 has not started. + +Tests 1B–1D subsequently passed. Test 3B received HTTP 402 from the external httpbingo endpoint, which is recorded as an unavailable test dependency rather than a Conductor result. The manual procedure now uses a Compose-only local mock and exact server-controlled internal-origin exception. Controlled HTTP 200, one-second delay, and HTTP 503 responses all passed through the proxy; manual Test 3B retest remains pending. + +The controlled-fixture continuation passed Tests 1–5. Test 6A found that JSON Editor omitted the shared validation summary, while 6B was not run because the partial JSON-edit instruction was too vague. JSON Editor now includes the shared summary with regression coverage; 24 frontend suites / 515 tests and production build pass, and Docker was rebuilt. A complete `SLICE5_TEST6B_PROJECT.json` replaces the partial-edit instruction and produces exactly the intended transform compatibility warning. Retest 6A, Test 6B, and cleanup remain pending. + +Retest 6A and prepared-document Test 6B passed, and the user confirmed all tests pass. Cleanup cleared all disposable execution history, deleted project `#14`, removed the mock fixture/internal exception, restored standard Compose, and confirmed healthy backend/frontend plus empty executions, secrets, secret-key configuration, and internal-origin configuration. Manual acceptance is complete; explicit final Slice 5 sign-off remains pending. + +The user granted explicit final Slice 5 sign-off on 2026-08-04 with the wording: “Confirm slice5 signoff.” Slice 5 is complete. + +## Slice 4 Manual Gate + +The five workflows in `SLICE4.md` passed on 2026-08-02 after focused remediation and repetition. Initial testing passed approved destinations, controls/recovery, and execution history; mixed-answer/rebinding DNS checks were initially unavailable, and private destination URLs in backend failure logs failed redaction. The logger was reduced to safe structured metadata. A disposable authoritative DNS fixture then proved mixed public/loopback answers fail closed and rebinding cannot bypass pinned validated addresses. The focused logging retest found zero private literals or raw transport errors. IPv6 literal classification was also corrected and retested. All disposable secrets, history, fixtures, networks, keys, and exceptions were removed. This is manual evidence separate from automation. + +An independent manual re-test result was supplied on 2026-08-02 using rebuilt Docker Compose and isolated Playwright Chromium. Every previously failed or unavailable check passed: approved-destination and credential regressions, mixed-answer DNS, rebinding/address pinning, loopback IPv4/IPv6 classification, all credential modes across success/failure/redirect paths, exact marker searches, safe backend logging, and cleanup. The reported backend regression result was 18/18 passing. No source was edited and nothing was committed or pushed during that re-test. No remaining manual limitation was identified. The tester explicitly stated that this result is not final Slice 4 sign-off. + +The user granted final Slice 4 sign-off on 2026-08-03 with the exact wording: “I grant explicit final Slice 4 sign-off.” Final consolidated validation then passed: backend build and 7 compiled test files, 22 frontend suites / 509 tests, frontend production build, the 13 / 2 / 2 schema matrix, rebuilt Docker Compose services, backend health, frontend HTTP 200, and empty secrets/history/configuration cleanup checks. Slice 4 is complete; no further Slice 4 manual testing is required. ## Test Environment Setup diff --git a/backend/package.json b/backend/package.json index ee36ece..7fca757 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/index.ts", "build": "tsc", - "test": "npm run build && node --test dist/lib/secretCrypto.test.js dist/routes/proxy.test.js", + "test": "npm run build && node --test dist/**/*.test.js", "start": "node dist/index.js" }, "dependencies": { diff --git a/backend/src/app.ts b/backend/src/app.ts index f5812cb..1702188 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,12 +6,13 @@ import projectsRouter from './routes/projects'; import validateRouter from './routes/validate'; import proxyRouter from './routes/proxy'; import secretsRouter from './routes/secrets'; +import executionsRouter from './routes/executions'; const app: Application = express(); // ── Middleware ──────────────────────────────────────────────────────────────── -app.use(express.json()); +app.use(express.json({ limit: '2mb' })); app.use(morgan('dev')); // ── Routes ──────────────────────────────────────────────────────────────────── @@ -20,6 +21,7 @@ app.use('/api/health', healthRouter); app.use('/api/projects/validate', validateRouter); app.use('/api/projects', projectsRouter); app.use('/api/secrets', secretsRouter); +app.use('/api/executions', executionsRouter); app.use('/api/proxy/execute', proxyRouter); // ── Catch-all 404 ───────────────────────────────────────────────────────────── @@ -33,6 +35,10 @@ app.use((_req: Request, res: Response) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars app.use((err: Error, _req: Request, res: Response, next: NextFunction) => { + if ((err as Error & { type?: string }).type === 'entity.too.large') { + res.status(413).json({ code: 'PROXY_API_REQUEST_TOO_LARGE', error: 'Proxy API request exceeded the 2 MiB limit.' }); + return; + } if (err instanceof SyntaxError && 'body' in err) { res.status(400).json({ error: 'Invalid JSON: request body could not be parsed.' }); return; diff --git a/backend/src/db/executions.ts b/backend/src/db/executions.ts new file mode 100644 index 0000000..a57bcfd --- /dev/null +++ b/backend/src/db/executions.ts @@ -0,0 +1,19 @@ +import db from './database'; + +export type ExecutionOutcome = 'success'|'upstream_error'|'policy_rejected'|'timeout'|'size_limited'|'network_error'; +export type ExecutionInput = { projectId?: string; projectName?: string; actionId?: string; actionName?: string; method: string; url: string; status?: number; durationMs: number; outcome: ExecutionOutcome; errorCode?: string; errorMessage?: string; preview?: string; responseBytes?: number; previewTruncated?: boolean; contentType?: string }; + +export function recordExecution(value: ExecutionInput): void { + const transaction = db.transaction(() => { + db.prepare(`INSERT INTO executions (project_id,project_name,action_id,action_name,method,sanitized_url,status,duration_ms,outcome,error_code,error_message,preview,response_bytes,preview_truncated,content_type) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(value.projectId??null,value.projectName??null,value.actionId??null,value.actionName??null,value.method,value.url,value.status??null,value.durationMs,value.outcome,value.errorCode??null,value.errorMessage??null,value.preview??null,value.responseBytes??null,value.previewTruncated?1:0,value.contentType??null); + db.prepare('DELETE FROM executions WHERE id NOT IN (SELECT id FROM executions ORDER BY id DESC LIMIT 1000)').run(); + }); transaction(); +} + +export function listExecutions(filters: Record): unknown[] { + const clauses: string[]=[]; const params: unknown[]=[]; + for (const [query,column] of [['projectId','project_id'],['actionId','action_id'],['outcome','outcome'],['status','status']] as const) if (typeof filters[query] === 'string' && filters[query]) { clauses.push(`${column} = ?`); params.push(filters[query]); } + const requested=Number(filters.limit); const limit=Number.isInteger(requested)?Math.max(1,Math.min(100,requested)):50; + return db.prepare(`SELECT id,created_at AS createdAt,project_id AS projectId,project_name AS projectName,action_id AS actionId,action_name AS actionName,method,sanitized_url AS sanitizedUrl,status,duration_ms AS durationMs,outcome,error_code AS errorCode,error_message AS errorMessage,preview,response_bytes AS responseBytes,preview_truncated AS previewTruncated,content_type AS contentType FROM executions ${clauses.length?'WHERE '+clauses.join(' AND '):''} ORDER BY id DESC LIMIT ?`).all(...params,limit); +} +export function clearExecutions(): void { db.prepare('DELETE FROM executions').run(); } diff --git a/backend/src/db/init.ts b/backend/src/db/init.ts index 86b8dfc..9ddb14c 100644 --- a/backend/src/db/init.ts +++ b/backend/src/db/init.ts @@ -25,6 +25,18 @@ export function initDatabase(): void { created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); + + CREATE TABLE IF NOT EXISTS executions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + project_id TEXT, project_name TEXT, action_id TEXT, action_name TEXT, + method TEXT NOT NULL, sanitized_url TEXT NOT NULL, status INTEGER, + duration_ms INTEGER NOT NULL, outcome TEXT NOT NULL, error_code TEXT, + error_message TEXT, preview TEXT, response_bytes INTEGER, + preview_truncated INTEGER NOT NULL DEFAULT 0, content_type TEXT + ); + CREATE INDEX IF NOT EXISTS idx_executions_action ON executions(action_id, id DESC); + CREATE INDEX IF NOT EXISTS idx_executions_outcome ON executions(outcome, id DESC); `); console.log('Database initialised'); diff --git a/backend/src/db/projects.ts b/backend/src/db/projects.ts index d94140f..a89dd71 100644 --- a/backend/src/db/projects.ts +++ b/backend/src/db/projects.ts @@ -1,4 +1,5 @@ import db from './database'; +import { validateProjectDocument } from '../lib/validateProject'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -23,17 +24,17 @@ export type UpdateProjectInput = { project_json?: string; }; -// Minimal valid project definition created when none is supplied on POST -const DEFAULT_PROJECT_JSON = JSON.stringify({ - schemaVersion: '0.1.0', - project: { - name: '', - pages: [], - actions: [], - variables: {}, - settings: {}, - }, -}); +export function defaultProjectDocument(name: string): Record { + return { + schemaVersion: '0.1.0', + project: { + id: `project_${Date.now()}`, + name, + pages: [{ id: 'page_main', name: 'Main Page', order: 0, components: [] }], + actions: [], bindings: [], variables: {}, settings: {}, + }, + }; +} // ── Queries ─────────────────────────────────────────────────────────────────── @@ -65,6 +66,14 @@ const stmtDelete = db.prepare<[number], void>(` DELETE FROM projects WHERE id = ? `); +function assertValidProjectJson(projectJson: string): void { + let document: unknown; + try { document = JSON.parse(projectJson); } + catch { throw new Error('Project persistence rejected malformed JSON.'); } + const result = validateProjectDocument(document); + if (!result.valid) throw new Error(`Project persistence rejected invalid document: ${result.errors[0]?.message ?? 'unknown validation error'}`); +} + // ── Data access functions ───────────────────────────────────────────────────── export function listProjects(): ProjectRow[] { @@ -76,12 +85,9 @@ export function getProjectById(id: number): ProjectRow | undefined { } export function createProject(input: CreateProjectInput): ProjectRow { - const { name, description = '', project_json = DEFAULT_PROJECT_JSON } = input; - // Embed the project name into the default JSON so it is consistent - const json = - project_json === DEFAULT_PROJECT_JSON - ? JSON.stringify({ ...JSON.parse(DEFAULT_PROJECT_JSON), project: { ...JSON.parse(DEFAULT_PROJECT_JSON).project, name } }) - : project_json; + const { name, description = '', project_json } = input; + const json = project_json ?? JSON.stringify(defaultProjectDocument(name)); + assertValidProjectJson(json); const result = stmtInsert.run(name, description, json); return getProjectById(result.lastInsertRowid as number) as ProjectRow; } @@ -89,10 +95,12 @@ export function createProject(input: CreateProjectInput): ProjectRow { export function updateProject(id: number, input: UpdateProjectInput): ProjectRow | undefined { const existing = getProjectById(id); if (!existing) return undefined; + const projectJson = input.project_json ?? existing.project_json; + assertValidProjectJson(projectJson); stmtUpdate.run( input.name ?? existing.name, input.description ?? existing.description, - input.project_json ?? existing.project_json, + projectJson, id, ); return getProjectById(id); diff --git a/backend/src/lib/proxyLogging.test.ts b/backend/src/lib/proxyLogging.test.ts new file mode 100644 index 0000000..d3bac80 --- /dev/null +++ b/backend/src/lib/proxyLogging.test.ts @@ -0,0 +1,10 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { safeProxyFailureLog } from './proxyLogging'; + +test('proxy failure logs contain only non-sensitive troubleshooting metadata', () => { + const entry=safeProxyFailureLog('GET','PROXY_DESTINATION_FORBIDDEN','action-safe-id'); + assert.deepEqual(entry,{event:'proxy_request_failed',method:'GET',code:'PROXY_DESTINATION_FORBIDDEN',actionId:'action-safe-id'}); + const serialized=JSON.stringify(entry); + for(const forbidden of ['url','host','address','dns','error','project','name','127.0.0.1','169.254.169.254']) assert.equal(serialized.toLowerCase().includes(forbidden),false,forbidden); +}); diff --git a/backend/src/lib/proxyLogging.ts b/backend/src/lib/proxyLogging.ts new file mode 100644 index 0000000..d67faa9 --- /dev/null +++ b/backend/src/lib/proxyLogging.ts @@ -0,0 +1,15 @@ +export type SafeProxyFailureLog = { + event: 'proxy_request_failed'; + method: string; + code: string; + actionId?: string; +}; + +/** + * Produces deliberately minimal structured log metadata. Destination URLs, + * hostnames, DNS answers, resolved addresses, project names, action names, + * credentials, and transport errors are never part of this object. + */ +export function safeProxyFailureLog(method: string, code: string, actionId?: string): SafeProxyFailureLog { + return { event: 'proxy_request_failed', method, code, ...(actionId ? { actionId } : {}) }; +} diff --git a/backend/src/lib/proxyPolicy.test.ts b/backend/src/lib/proxyPolicy.test.ts new file mode 100644 index 0000000..78e7da5 --- /dev/null +++ b/backend/src/lib/proxyPolicy.test.ts @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isUnsafeAddress, sanitizeUrl, validateDestination, validateRequestHeaders } from './proxyPolicy'; + +test('classifies unsafe IPv4, IPv6, and mapped addresses', () => { + for (const ip of ['127.0.0.1','10.1.2.3','169.254.169.254','172.31.1.1','192.168.1.1','::1','fd00::1','fe80::1','2001:db8::1','2002::1','64:ff9b::1','::ffff:127.0.0.1']) assert.equal(isUnsafeAddress(ip), true, ip); + for (const ip of ['8.8.8.8','1.1.1.1','2606:4700:4700::1111']) assert.equal(isUnsafeAddress(ip), false, ip); +}); + +test('validates every DNS answer and rejects unsafe schemes and URL credentials', async () => { + const mixed = async () => [{address:'8.8.8.8',family:4},{address:'127.0.0.1',family:4}]; + await assert.rejects(validateDestination('https://example.test', mixed as never), (e: any) => e.code === 'PROXY_DESTINATION_FORBIDDEN'); + await assert.rejects(validateDestination('http://[::1]/'), (e: any) => e.code === 'PROXY_DESTINATION_FORBIDDEN'); + await assert.rejects(validateDestination('file:///etc/passwd'), (e: any) => e.code === 'PROXY_SCHEME_FORBIDDEN'); + await assert.rejects(validateDestination('https://user:pass@example.test'), (e: any) => e.code === 'PROXY_URL_CREDENTIALS_FORBIDDEN'); +}); + +test('allows safe headers and rejects credential, hop-by-hop, and arbitrary headers', () => { + assert.deepEqual(validateRequestHeaders({Accept:'application/json','X-Trace-Id':'1'}), {Accept:'application/json','X-Trace-Id':'1'}); + for (const name of ['Authorization','Cookie','Host','Connection','X-Forwarded-For','Random']) assert.throws(() => validateRequestHeaders({[name]:'x'})); +}); + +test('sanitizes URL credentials and every query value', () => { + const value = sanitizeUrl('https://user:pass@example.test/path?token=secret&plain=value'); + assert.ok(!value.includes('secret')); assert.ok(!value.includes('value')); assert.ok(!value.includes('pass')); +}); diff --git a/backend/src/lib/proxyPolicy.ts b/backend/src/lib/proxyPolicy.ts new file mode 100644 index 0000000..198d886 --- /dev/null +++ b/backend/src/lib/proxyPolicy.ts @@ -0,0 +1,84 @@ +import dns from 'node:dns/promises'; +import net from 'node:net'; + +export const MAX_REQUEST_BODY_BYTES = 1024 * 1024; +export const MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024; +export const MAX_REDIRECTS = 3; +export const REQUEST_TIMEOUT_MS = 30_000; + +export class ProxyPolicyError extends Error { + constructor(public code: string, message: string, public status = 400) { super(message); this.name = 'ProxyPolicyError'; } +} + +const allowedHeaders = new Set(['accept', 'accept-language', 'content-type', 'if-match', 'if-none-match', 'idempotency-key']); +const prohibitedHeaders = new Set(['authorization','cookie','cookie2','host','connection','content-length','transfer-encoding','te','trailer','upgrade','keep-alive','proxy-authorization','proxy-authenticate','forwarded','via']); + +export function validateRequestHeaders(headers: Record): Record { + const result: Record = {}; + for (const [name, value] of Object.entries(headers)) { + const lower = name.toLowerCase(); + const credentialLike = lower.includes('authorization') || lower.includes('cookie') || lower.includes('api-key') || lower.includes('apikey') || lower.includes('token') || lower.includes('secret'); + if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/i.test(name) || credentialLike || prohibitedHeaders.has(lower) || lower.startsWith('proxy-') || lower.startsWith('sec-') || lower.startsWith('x-forwarded-')) { + throw new ProxyPolicyError('PROXY_HEADER_FORBIDDEN', `Request header "${name}" is not permitted.`); + } + if (!allowedHeaders.has(lower) && !lower.startsWith('x-')) throw new ProxyPolicyError('PROXY_HEADER_FORBIDDEN', `Request header "${name}" is not permitted.`); + if (/\r|\n/.test(value)) throw new ProxyPolicyError('PROXY_HEADER_INVALID', `Request header "${name}" has an invalid value.`); + result[name] = value; + } + return result; +} + +function ipv4Number(ip: string): number { return ip.split('.').reduce((n, part) => (n * 256 + Number(part)) >>> 0, 0); } +function inV4(ip: string, base: string, bits: number): boolean { const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; return (ipv4Number(ip) & mask) === (ipv4Number(base) & mask); } +function ipv6Number(ip: string): bigint { + let value=ip.toLowerCase(); + const dotted=value.match(/(\d+\.\d+\.\d+\.\d+)$/); + if(dotted){const n=ipv4Number(dotted[1]);value=value.slice(0,-dotted[1].length)+`${(n>>>16).toString(16)}:${(n&0xffff).toString(16)}`;} + const sides=value.split('::'); const left=sides[0]?sides[0].split(':'):[]; const right=sides[1]?sides[1].split(':'):[]; const groups=sides.length===2?[...left,...Array(8-left.length-right.length).fill('0'),...right]:left; + if(groups.length!==8) throw new Error('invalid IPv6'); + return groups.reduce((result,group)=>(result<<16n)+BigInt(`0x${group||'0'}`),0n); +} +function inV6(ip:string,base:string,bits:number):boolean { const shift=BigInt(128-bits); return (ipv6Number(ip)>>shift)===(ipv6Number(base)>>shift); } + +export function isUnsafeAddress(address: string): boolean { + const family = net.isIP(address); + if (family === 4) return [ + ['0.0.0.0',8],['10.0.0.0',8],['100.64.0.0',10],['127.0.0.0',8],['169.254.0.0',16],['172.16.0.0',12],['192.0.0.0',24],['192.0.2.0',24],['192.88.99.0',24],['192.168.0.0',16],['198.18.0.0',15],['198.51.100.0',24],['203.0.113.0',24],['224.0.0.0',4],['240.0.0.0',4], + ].some(([base,bits]) => inV4(address, String(base), Number(bits))); + if (family === 6) { + const normalized = address.toLowerCase(); + const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isUnsafeAddress(mapped[1]); + return [['::',128],['::1',128],['64:ff9b::',96],['64:ff9b:1::',48],['100::',64],['2001::',23],['2001:db8::',32],['2002::',16],['fc00::',7],['fe80::',10],['ff00::',8]].some(([base,bits])=>inV6(normalized,String(base),Number(bits))); + } + return true; +} + +function configuredInternalOrigins(): Set { + const result = new Set(); + for (const raw of (process.env.CONDUCTOR_PROXY_INTERNAL_ORIGINS ?? '').split(',').map(v => v.trim()).filter(Boolean)) { + try { const u = new URL(raw); if ((u.protocol === 'http:' || u.protocol === 'https:') && !u.username && !u.password && u.pathname === '/' && !u.search && !u.hash && !net.isIP(u.hostname)) result.add(u.origin); } catch { /* fail closed */ } + } + return result; +} + +export type ValidatedDestination = { url: URL; address: string; family: 4 | 6 }; +export async function validateDestination(raw: string, lookup = dns.lookup): Promise { + let url: URL; + try { url = new URL(raw); } catch { throw new ProxyPolicyError('PROXY_URL_INVALID', 'Destination URL is invalid.'); } + if (!['http:','https:'].includes(url.protocol)) throw new ProxyPolicyError('PROXY_SCHEME_FORBIDDEN', 'Only HTTP and HTTPS destinations are permitted.'); + if (url.username || url.password) throw new ProxyPolicyError('PROXY_URL_CREDENTIALS_FORBIDDEN', 'Destination URLs cannot contain credentials.'); + const internalAllowed = configuredInternalOrigins().has(url.origin); + const hostname = url.hostname.startsWith('[') && url.hostname.endsWith(']') ? url.hostname.slice(1,-1) : url.hostname; + let answers: Array<{address: string; family: number}>; + if (net.isIP(hostname)) answers = [{ address: hostname, family: net.isIP(hostname) }]; + else { try { answers = await lookup(hostname, { all: true, verbatim: true }) as Array<{address:string;family:number}>; } catch { throw new ProxyPolicyError('PROXY_DNS_FAILED', 'Destination hostname could not be resolved.', 502); } } + if (!answers.length) throw new ProxyPolicyError('PROXY_DNS_FAILED', 'Destination hostname could not be resolved.', 502); + if (!internalAllowed && answers.some(a => isUnsafeAddress(a.address))) throw new ProxyPolicyError('PROXY_DESTINATION_FORBIDDEN', 'Destination is blocked by proxy security policy.'); + const selected = answers[0]; + return { url, address: selected.address, family: selected.family as 4 | 6 }; +} + +export function sanitizeUrl(raw: string): string { + try { const u = new URL(raw); u.username = u.username ? '[REDACTED]' : ''; u.password = u.password ? '[REDACTED]' : ''; for (const key of [...u.searchParams.keys()]) u.searchParams.set(key, '[REDACTED]'); return u.toString(); } catch { return '[invalid or redacted URL]'; } +} diff --git a/backend/src/lib/proxyTransport.test.ts b/backend/src/lib/proxyTransport.test.ts new file mode 100644 index 0000000..078a876 --- /dev/null +++ b/backend/src/lib/proxyTransport.test.ts @@ -0,0 +1,5 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; +import { requestWithPolicy, type TransportOnce, type TransportResult } from './proxyTransport'; +const validate=async(raw:string)=>({url:new URL(raw),address:'8.8.8.8',family:4 as const}); +test('revalidates every redirect and strips cross-origin credentials in the transport loop',async()=>{const urls:string[]=[];const seen:Record[]=[];const once:TransportOnce=async(destination,_method,headers)=>{urls.push(destination.url.toString());seen.push({...headers});const result:TransportResult=urls.length===1?{status:302,statusText:'Found',headers:{location:'https://other.example/end'},body:Buffer.alloc(0),finalUrl:destination.url.toString(),redirects:0}:{status:200,statusText:'OK',headers:{},body:Buffer.from('ok'),finalUrl:destination.url.toString(),redirects:0};return result;};const result=await requestWithPolicy('https://start.example/','GET',{Authorization:'secret',Accept:'application/json'},undefined,{validate,once});assert.equal(result.status,200);assert.deepEqual(urls,['https://start.example/','https://other.example/end']);assert.deepEqual(seen[1],{Accept:'application/json'});}); +test('fails with the safe timeout code',async()=>{const once:TransportOnce=(_destination,_method,_headers,_body,signal)=>new Promise((_resolve,reject)=>signal.addEventListener('abort',()=>reject(new Error('raw abort detail'))));await assert.rejects(requestWithPolicy('https://example.test/','GET',{},undefined,{validate,once,timeoutMs:5}),(e:any)=>e.code==='PROXY_TIMEOUT'&&!e.message.includes('raw abort'));}); diff --git a/backend/src/lib/proxyTransport.ts b/backend/src/lib/proxyTransport.ts new file mode 100644 index 0000000..abc4a9a --- /dev/null +++ b/backend/src/lib/proxyTransport.ts @@ -0,0 +1,57 @@ +import http from 'node:http'; +import https from 'node:https'; +import { ProxyPolicyError, REQUEST_TIMEOUT_MS, validateDestination, type ValidatedDestination } from './proxyPolicy'; +import { prepareRedirect } from './redirectPolicy'; +import { appendBoundedChunk } from './responseBody'; + +export type TransportResult = { status: number; statusText: string; headers: Record; body: Buffer; finalUrl: string; redirects: number }; +const redirectStatuses = new Set([301,302,303,307,308]); + +export type TransportOnce = (destination: ValidatedDestination, method: string, headers: Record, body: string | undefined, signal: AbortSignal) => Promise; +const once: TransportOnce = (destination, method, headers, body, signal) => { + return new Promise((resolve, reject) => { + const client = destination.url.protocol === 'https:' ? https : http; + const req = client.request(destination.url, { + method, headers, signal, servername: destination.url.hostname, + lookup: (_hostname, options, callback) => { + if (typeof options === 'object' && options.all) (callback as unknown as (error: null, addresses: Array<{address:string;family:number}>) => void)(null, [{address:destination.address,family:destination.family}]); + else callback(null, destination.address, destination.family); + }, + }, response => { + const chunks: Buffer[] = []; let size = 0; + response.on('data', (chunk: Buffer) => { + try { size=appendBoundedChunk(chunks,chunk,size); } catch(error) { response.destroy(error as Error); } + }); + response.on('end', () => { + const safeHeaders: Record = {}; + for (const name of ['content-type','location','cache-control','etag','last-modified','x-request-id','x-correlation-id','x-ratelimit-limit','x-ratelimit-remaining','x-ratelimit-reset']) { + const value = response.headers[name]; if (typeof value === 'string') safeHeaders[name] = value; + } + resolve({ status: response.statusCode ?? 502, statusText: response.statusMessage ?? '', headers: safeHeaders, body: Buffer.concat(chunks), finalUrl: destination.url.toString(), redirects: 0 }); + }); + response.on('error', reject); + }); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); +} + +export async function requestWithPolicy(url: string, method: string, headers: Record, body?: string, dependencies: {validate?:typeof validateDestination;once?:TransportOnce;timeoutMs?:number}={}): Promise { + const validate=dependencies.validate??validateDestination; const send=dependencies.once??once; const timeoutMs=dependencies.timeoutMs??REQUEST_TIMEOUT_MS; + const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); + let currentUrl = url; let currentMethod = method; let currentBody = body; let currentHeaders = {...headers}; + try { + for (let redirects = 0; ; redirects++) { + const destination = await validate(currentUrl); + const result = await send(destination, currentMethod, currentHeaders, currentBody, controller.signal); + const location = result.headers.location; + if (!redirectStatuses.has(result.status) || !location) return {...result, redirects}; + const next=prepareRedirect({url:currentUrl,method:currentMethod,headers:currentHeaders,body:currentBody},result.status,location,redirects); + currentUrl=next.url; currentMethod=next.method; currentHeaders=next.headers; currentBody=next.body; + } + } catch (error) { + if (controller.signal.aborted) throw new ProxyPolicyError('PROXY_TIMEOUT', `Upstream did not respond within ${REQUEST_TIMEOUT_MS / 1000} seconds.`, 504); + throw error; + } finally { clearTimeout(timer); } +} diff --git a/backend/src/lib/redirectPolicy.test.ts b/backend/src/lib/redirectPolicy.test.ts new file mode 100644 index 0000000..1fe5e92 --- /dev/null +++ b/backend/src/lib/redirectPolicy.test.ts @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { prepareRedirect } from './redirectPolicy'; + +test('resolves redirect targets and strips credentials on origin changes', () => { + const prior={url:'https://public.example/start',method:'GET',headers:{Authorization:'Bearer disposable','X-API-Key':'disposable',Accept:'application/json'}}; + const next=prepareRedirect(prior,302,'https://other.example/end',0); + assert.equal(next.url,'https://other.example/end'); assert.deepEqual(next.headers,{Accept:'application/json'}); + assert.equal(prior.headers.Authorization,'Bearer disposable'); +}); +test('preserves same-origin headers, applies method rules, and limits hops', () => { + const state={url:'https://public.example/start',method:'POST',headers:{Authorization:'x'},body:'{}'}; + assert.deepEqual(prepareRedirect(state,307,'/next',0),{...state,url:'https://public.example/next'}); + const changed=prepareRedirect(state,303,'/next',0); assert.equal(changed.method,'GET'); assert.equal(changed.body,undefined); + assert.throws(()=>prepareRedirect(state,302,'/next',3),(error:any)=>error.code==='PROXY_REDIRECT_LIMIT'); +}); diff --git a/backend/src/lib/redirectPolicy.ts b/backend/src/lib/redirectPolicy.ts new file mode 100644 index 0000000..1c5a0c4 --- /dev/null +++ b/backend/src/lib/redirectPolicy.ts @@ -0,0 +1,10 @@ +import { MAX_REDIRECTS, ProxyPolicyError } from './proxyPolicy'; + +export type RedirectState = { url: string; method: string; headers: Record; body?: string }; +export function prepareRedirect(state: RedirectState, status: number, location: string, completedRedirects: number): RedirectState { + if (completedRedirects >= MAX_REDIRECTS) throw new ProxyPolicyError('PROXY_REDIRECT_LIMIT', `Upstream exceeded the ${MAX_REDIRECTS}-redirect limit.`, 502); + const prior=new URL(state.url); const next=new URL(location,prior); const headers={...state.headers}; let method=state.method; let body=state.body; + if(next.origin!==prior.origin) for(const name of Object.keys(headers)) if(!['accept','accept-language'].includes(name.toLowerCase())) delete headers[name]; + if(status===303||((status===301||status===302)&&method==='POST')){method='GET';body=undefined;} + return {url:next.toString(),method,headers,body}; +} diff --git a/backend/src/lib/responseBody.test.ts b/backend/src/lib/responseBody.test.ts new file mode 100644 index 0000000..4d8d718 --- /dev/null +++ b/backend/src/lib/responseBody.test.ts @@ -0,0 +1,4 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; +import { appendBoundedChunk, parseResponseBody } from './responseBody'; +test('bounds streamed response accumulation before retaining an oversized chunk',()=>{const chunks:Buffer[]=[];let size=appendBoundedChunk(chunks,Buffer.from('1234'),0,5);assert.equal(size,4);assert.throws(()=>appendBoundedChunk(chunks,Buffer.from('67'),size,5),(e:any)=>e.code==='PROXY_RESPONSE_TOO_LARGE');assert.equal(chunks.length,1);}); +test('parses valid JSON and safely returns malformed JSON as text',()=>{assert.deepEqual(parseResponseBody(Buffer.from('{"ok":true}'),'application/json'),{ok:true});assert.equal(parseResponseBody(Buffer.from('{bad'),'application/json'),'{bad');assert.equal(parseResponseBody(Buffer.from('hello'),'text/plain'),'hello');}); diff --git a/backend/src/lib/responseBody.ts b/backend/src/lib/responseBody.ts new file mode 100644 index 0000000..ac70d31 --- /dev/null +++ b/backend/src/lib/responseBody.ts @@ -0,0 +1,12 @@ +import { MAX_RESPONSE_BODY_BYTES, ProxyPolicyError } from './proxyPolicy'; + +export function appendBoundedChunk(chunks: Buffer[], chunk: Buffer, currentSize: number, limit=MAX_RESPONSE_BODY_BYTES): number { + const next=currentSize+chunk.length; + if(next>limit) throw new ProxyPolicyError('PROXY_RESPONSE_TOO_LARGE', 'Upstream response exceeded the 5 MiB limit.',502); + chunks.push(chunk); return next; +} +export function parseResponseBody(body: Buffer, contentType: string): unknown { + const text=body.toString('utf8'); + if(contentType.toLowerCase().includes('application/json')) { try{return JSON.parse(text);}catch{return text;} } + return text; +} diff --git a/backend/src/lib/validateProject.test.ts b/backend/src/lib/validateProject.test.ts new file mode 100644 index 0000000..c47e6ec --- /dev/null +++ b/backend/src/lib/validateProject.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { validateProjectDocument } from './validateProject'; + +function validDocument(): any { + return { + schemaVersion: '0.1.0', + project: { + id: 'p1', name: 'Project', + pages: [{ id: 'page1', name: 'Main', components: [ + { id: 'button1', type: 'Button', name: 'submit', position: { x: 0, y: 0 }, size: { width: 100, height: 30 }, properties: {}, events: [{ event: 'onClick', actionId: 'a1' }] }, + { id: 'viewer1', type: 'JsonViewer', name: 'viewer', position: { x: 0, y: 40 }, size: { width: 200, height: 100 }, properties: {} }, + ] }], + actions: [{ id: 'a1', name: 'Load', method: 'GET', url: 'https://example.com', authenticationType: 'anonymous' }], + bindings: [{ id: 'b1', source: 'actions.a1.response.body', target: 'components.viewer.value', trigger: 'onSuccess' }], + variables: { count: { type: 'number', defaultValue: 0 }, optional: { type: 'string', defaultValue: null } }, + settings: { defaultPageId: 'page1' }, + }, + }; +} + +test('accepts a canonical v0.1.0 document and explicit null', () => { + assert.deepEqual(validateProjectDocument(validDocument()), { valid: true, errors: [], warnings: [] }); +}); + +test('rejects unsupported versions with an actionable path', () => { + const doc = validDocument(); doc.schemaVersion = '1.0.0'; + const result = validateProjectDocument(doc); + assert.equal(result.valid, false); + assert.equal(result.errors[0].code, 'SCHEMA_VERSION_UNSUPPORTED'); + assert.equal(result.errors[0].path, '/schemaVersion'); +}); + +test('rejects duplicate identities and dangling references', () => { + const doc = validDocument(); + doc.project.actions.push({ ...doc.project.actions[0] }); + doc.project.pages[0].components[0].events[0].actionId = 'missing'; + const result = validateProjectDocument(doc); + assert.equal(result.valid, false); + assert.ok(result.errors.some((issue) => issue.code === 'DUPLICATE_ACTION_ID')); + assert.ok(result.errors.some((issue) => issue.code === 'DUPLICATE_ACTION_NAME')); + assert.ok(result.errors.some((issue) => issue.code === 'DANGLING_ACTION_REFERENCE')); +}); + +test('rejects incompatible binding targets and variable defaults', () => { + const doc = validDocument(); + doc.project.bindings[0].target = 'components.submit.rows'; + doc.project.variables.count.defaultValue = 'not a number'; + const result = validateProjectDocument(doc); + assert.ok(result.errors.some((issue) => issue.code === 'BINDING_TARGET_INCOMPATIBLE')); + assert.ok(result.errors.some((issue) => issue.code === 'VARIABLE_DEFAULT_TYPE_MISMATCH')); +}); + +test('reports modeled but inert legacy fields as non-blocking warnings', () => { + const doc = validDocument(); + doc.project.bindings[0].transform = 'value'; + doc.project.actions[0].responseMapping = [{ source: 'body', target: 'components.viewer.value' }]; + doc.project.pages[0].components[0].events[0].inputMap = { value: 'legacy' }; + const result = validateProjectDocument(doc); + assert.equal(result.valid, true); + assert.deepEqual(new Set(result.warnings.map((issue) => issue.code)), new Set([ + 'LEGACY_INPUT_MAP', 'BINDING_TRANSFORM_DEFERRED', 'RESPONSE_MAPPING_DEPRECATED', + ])); +}); + +test('schema failures use precise required-property paths', () => { + const doc = validDocument(); delete doc.project.id; + const result = validateProjectDocument(doc); + assert.equal(result.valid, false); + assert.equal(result.errors[0].code, 'SCHEMA_INVALID'); + assert.equal(result.errors[0].path, '/project/id'); +}); + +test('rejects component-specific property and template defects', () => { + const doc = validDocument(); + doc.project.pages[0].components[0].properties.options = [{ label: 'A', value: 'same' }, { label: 'B', value: 'same' }]; + doc.project.actions[0].url = 'https://example.com/{{components.missing.value}}/{{variables.unknown}}'; + const result = validateProjectDocument(doc); + assert.ok(result.errors.some((issue) => issue.code === 'COMPONENT_PROPERTY_UNSUPPORTED')); + assert.ok(result.errors.some((issue) => issue.code === 'DUPLICATE_OPTION_VALUE')); + assert.ok(result.errors.some((issue) => issue.code === 'DANGLING_COMPONENT_REFERENCE')); + assert.ok(result.errors.some((issue) => issue.code === 'DANGLING_VARIABLE_REFERENCE')); +}); diff --git a/backend/src/lib/validateProject.ts b/backend/src/lib/validateProject.ts index 1d34c43..5de8636 100644 --- a/backend/src/lib/validateProject.ts +++ b/backend/src/lib/validateProject.ts @@ -1,69 +1,231 @@ import path from 'path'; import fs from 'fs'; -import Ajv2020 from 'ajv/dist/2020'; +import Ajv2020, { ErrorObject } from 'ajv/dist/2020'; import addFormats from 'ajv-formats'; -// ── Schema loading ──────────────────────────────────────────────────────────── +export const SUPPORTED_SCHEMA_VERSION = '0.1.0'; -// Resolve the shared schema at runtime so we stay inside rootDir: "src" for -// TypeScript compilation while still reading the canonical schema file. -const SCHEMA_PATH = path.resolve( - __dirname, - '../../../shared/schemas/conductor-project.schema.json', -); - -let _validate: ReturnType | null = null; +const SCHEMA_PATH = path.resolve(__dirname, '../../../shared/schemas/conductor-project.schema.json'); +let compiled: ReturnType | null = null; function getValidator(): ReturnType { - if (_validate) return _validate; - - let raw: string; - try { - raw = fs.readFileSync(SCHEMA_PATH, 'utf-8'); - } catch (err) { - throw new Error( - `[validateProject] Cannot read schema file at "${SCHEMA_PATH}": ${err instanceof Error ? err.message : String(err)}`, - ); - } - - const schema = JSON.parse(raw) as object; - + if (compiled) return compiled; + const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf-8')) as object; const ajv = new Ajv2020({ allErrors: true, strict: false }); addFormats(ajv); - - _validate = ajv.compile(schema); - return _validate; + compiled = ajv.compile(schema); + return compiled; } -// ── Public types ────────────────────────────────────────────────────────────── - -export type ValidationError = { +export type ValidationSeverity = 'error' | 'warning'; +export type ValidationIssue = { + code: string; path: string; message: string; + severity: ValidationSeverity; +}; +export type ValidationResult = { + valid: boolean; + errors: ValidationIssue[]; + warnings: ValidationIssue[]; }; -export type ValidationResult = - | { valid: true } - | { valid: false; errors: ValidationError[] }; +type JsonObject = Record; -// ── Validation function ─────────────────────────────────────────────────────── +function schemaPath(error: ErrorObject): string { + if (error.keyword === 'required' && typeof error.params.missingProperty === 'string') { + return `${error.instancePath || ''}/${error.params.missingProperty}` || '(root)'; + } + return error.instancePath || '(root)'; +} + +function add( + issues: ValidationIssue[], + code: string, + path: string, + message: string, + severity: ValidationSeverity = 'error', +): void { + issues.push({ code, path: path || '(root)', message, severity }); +} + +function duplicates(values: unknown[]): Set { + const seen = new Set(); + const found = new Set(); + for (const value of values) { + if (typeof value !== 'string') continue; + if (seen.has(value)) found.add(value); + seen.add(value); + } + return found; +} + +function valueMatchesType(value: unknown, type: string): boolean { + if (value === null) return true; + if (type === 'array') return Array.isArray(value); + if (type === 'object') return typeof value === 'object' && !Array.isArray(value); + return typeof value === type; +} + +function semanticIssues(doc: JsonObject): ValidationIssue[] { + const issues: ValidationIssue[] = []; + if (doc.schemaVersion !== SUPPORTED_SCHEMA_VERSION) { + add(issues, 'SCHEMA_VERSION_UNSUPPORTED', '/schemaVersion', + `Schema version "${String(doc.schemaVersion)}" is not supported. This release supports exactly ${SUPPORTED_SCHEMA_VERSION}.`); + } + + const project = doc.project as JsonObject; + const pages = project.pages as JsonObject[]; + const actions = project.actions as JsonObject[]; + const bindings = project.bindings as JsonObject[]; + const variables = project.variables as JsonObject; + + for (const id of duplicates(pages.map((page) => page.id))) + add(issues, 'DUPLICATE_PAGE_ID', '/project/pages', `Page id "${id}" is duplicated.`); + for (const name of duplicates(pages.map((page) => page.name))) + add(issues, 'DUPLICATE_PAGE_NAME', '/project/pages', `Page name "${name}" is duplicated.`); + for (const id of duplicates(actions.map((action) => action.id))) + add(issues, 'DUPLICATE_ACTION_ID', '/project/actions', `Action id "${id}" is duplicated.`); + for (const name of duplicates(actions.map((action) => action.name))) + add(issues, 'DUPLICATE_ACTION_NAME', '/project/actions', `Action name "${name}" is duplicated.`); + for (const id of duplicates(bindings.map((binding) => binding.id))) + add(issues, 'DUPLICATE_BINDING_ID', '/project/bindings', `Binding id "${id}" is duplicated.`); + + const actionIds = new Set(actions.map((action) => action.id)); + const bindingIds = new Set(bindings.map((binding) => binding.id)); + const components = pages.flatMap((page, pageIndex) => + (page.components as JsonObject[]).map((component, componentIndex) => ({ component, pageIndex, componentIndex }))); + const componentIds = new Set(components.map(({ component }) => component.id)); + const componentsByName = new Map(); + for (const { component } of components) { + const list = componentsByName.get(component.name) ?? []; + list.push(component); + componentsByName.set(component.name, list); + } + for (const id of duplicates(components.map(({ component }) => component.id))) + add(issues, 'DUPLICATE_COMPONENT_ID', '/project/pages', `Component id "${id}" is duplicated across the project.`); + for (const [name, matches] of componentsByName) { + if (matches.length > 1) + add(issues, 'DUPLICATE_COMPONENT_NAME', '/project/pages', `Component name "${name}" is duplicated; runtime references require project-wide unique names.`); + } + + const checkEvents = (events: JsonObject[] | undefined, path: string, allowed: Set) => { + (events ?? []).forEach((event, index) => { + if (!actionIds.has(event.actionId)) + add(issues, 'DANGLING_ACTION_REFERENCE', `${path}/${index}/actionId`, `Action "${event.actionId}" does not exist.`); + if (!allowed.has(event.event)) + add(issues, 'EVENT_UNSUPPORTED', `${path}/${index}/event`, `Event "${event.event}" is not supported here.`); + if (event.inputMap !== undefined) + add(issues, 'LEGACY_INPUT_MAP', `${path}/${index}/inputMap`, 'inputMap is retained for compatibility but is not executed; use action request templates.', 'warning'); + }); + }; + pages.forEach((page, index) => checkEvents(page.events, `/project/pages/${index}/events`, new Set(['onLoad']))); + components.forEach(({ component, pageIndex, componentIndex }) => { + const componentPath = `/project/pages/${pageIndex}/components/${componentIndex}`; + checkEvents(component.events, `/project/pages/${pageIndex}/components/${componentIndex}/events`, new Set(['onClick'])); + const properties = component.properties ?? {}; + const optionTypes = new Set(['Dropdown', 'RadioGroup']); + if (properties.options !== undefined && !optionTypes.has(component.type)) + add(issues, 'COMPONENT_PROPERTY_UNSUPPORTED', `${componentPath}/properties/options`, `${component.type} does not support options.`); + if ((properties.columns !== undefined || properties.rows !== undefined) && component.type !== 'Table') + add(issues, 'COMPONENT_PROPERTY_UNSUPPORTED', `${componentPath}/properties`, `${component.type} does not support table columns or rows.`); + if (Array.isArray(properties.options)) { + for (const value of duplicates(properties.options.map((option: JsonObject) => option.value))) + add(issues, 'DUPLICATE_OPTION_VALUE', `${componentPath}/properties/options`, `Option value "${value}" is duplicated.`); + if (typeof properties.value === 'string' && properties.value !== '' && !properties.options.some((option: JsonObject) => option.value === properties.value)) + add(issues, 'COMPONENT_DEFAULT_INVALID', `${componentPath}/properties/value`, `Configured value "${properties.value}" is not present in options.`); + } + if (Array.isArray(properties.columns)) { + for (const key of duplicates(properties.columns.map((column: JsonObject) => column.key))) + add(issues, 'DUPLICATE_TABLE_COLUMN', `${componentPath}/properties/columns`, `Table column key "${key}" is duplicated.`); + properties.columns.forEach((column: JsonObject, index: number) => { + if (!column.key) add(issues, 'TABLE_COLUMN_KEY_REQUIRED', `${componentPath}/properties/columns/${index}/key`, 'Table column key must not be empty.'); + }); + } + const configured = properties.value ?? properties.defaultValue; + if (configured !== undefined && configured !== null) { + const expected = component.type === 'Checkbox' ? 'boolean' + : new Set(['TextInput', 'TextArea', 'Dropdown', 'RadioGroup', 'Label', 'StatusPanel']).has(component.type) ? 'string' : null; + if (expected && typeof configured !== expected) + add(issues, 'COMPONENT_DEFAULT_TYPE_MISMATCH', `${componentPath}/properties`, `${component.type} configured value must be a ${expected}.`); + } + (component.bindings ?? []).forEach((ref: JsonObject, index: number) => { + if (!bindingIds.has(ref.bindingId)) + add(issues, 'DANGLING_BINDING_REFERENCE', `/project/pages/${pageIndex}/components/${componentIndex}/bindings/${index}/bindingId`, `Binding "${ref.bindingId}" does not exist.`); + add(issues, 'COMPONENT_BINDING_DEFERRED', `/project/pages/${pageIndex}/components/${componentIndex}/bindings/${index}`, 'Component-level binding references are modeled for compatibility but runtime uses project.bindings.', 'warning'); + }); + }); + + const supportedTargets: Record> = { + value: new Set(['JsonViewer', 'Label', 'TextArea', 'Checkbox', 'RadioGroup', 'StatusPanel']), + options: new Set(['Dropdown']), rows: new Set(['Table']), + }; + bindings.forEach((binding, index) => { + const path = `/project/bindings/${index}`; + const source = /^actions\.([^.]+)\.response(?:\.body(?:\.[^.]+)*)?$/.exec(binding.source); + if (!source) add(issues, 'BINDING_SOURCE_UNSUPPORTED', `${path}/source`, 'Binding source must be actions..response or a response.body dot path.'); + else if (!actionIds.has(source[1])) add(issues, 'DANGLING_ACTION_REFERENCE', `${path}/source`, `Action "${source[1]}" does not exist.`); + + const componentTarget = /^components\.([^.]+)\.(value|options|rows)$/.exec(binding.target); + const variableTarget = /^variables\.([^.]+)$/.exec(binding.target); + if (componentTarget) { + const matches = componentsByName.get(componentTarget[1]) ?? []; + if (matches.length === 0) add(issues, 'DANGLING_COMPONENT_REFERENCE', `${path}/target`, `Component "${componentTarget[1]}" does not exist.`); + else if (matches.length === 1 && !supportedTargets[componentTarget[2]].has(matches[0].type)) + add(issues, 'BINDING_TARGET_INCOMPATIBLE', `${path}/target`, `${matches[0].type} cannot receive the ${componentTarget[2]} property.`); + } else if (variableTarget) { + if (!Object.prototype.hasOwnProperty.call(variables, variableTarget[1])) + add(issues, 'DANGLING_VARIABLE_REFERENCE', `${path}/target`, `Variable "${variableTarget[1]}" does not exist.`); + } else add(issues, 'BINDING_TARGET_UNSUPPORTED', `${path}/target`, 'Binding target must be a supported component property or variables..'); + if (!['onSuccess', 'onClick'].includes(binding.trigger ?? 'onSuccess')) + add(issues, 'BINDING_TRIGGER_UNSUPPORTED', `${path}/trigger`, `Trigger "${binding.trigger}" is not supported for action-response bindings.`); + if (binding.transform !== undefined) + add(issues, 'BINDING_TRANSFORM_DEFERRED', `${path}/transform`, 'Binding transforms are modeled but are not executed in v0.1.0.', 'warning'); + }); + + actions.forEach((action, index) => { + const fields: Array<[string, string]> = [['url', action.url]]; + if (typeof action.bodyTemplate === 'string') fields.push(['bodyTemplate', action.bodyTemplate]); + for (const [group, record] of [['headers', action.headers], ['queryParameters', action.queryParameters], ['pathParameters', action.pathParameters]] as Array<[string, JsonObject | undefined]>) + Object.entries(record ?? {}).forEach(([key, value]) => fields.push([`${group}/${key}`, String(value)])); + fields.forEach(([field, value]) => { + const opens = (value.match(/\{\{/g) ?? []).length; + const closes = (value.match(/\}\}/g) ?? []).length; + if (opens !== closes) add(issues, 'TEMPLATE_MALFORMED', `/project/actions/${index}/${field}`, 'Template braces are not balanced.'); + for (const match of value.matchAll(/\{\{components\.([^.}]+)\.([^.}]+)\}\}/g)) { + if (!componentsByName.has(match[1])) add(issues, 'DANGLING_COMPONENT_REFERENCE', `/project/actions/${index}/${field}`, `Component "${match[1]}" does not exist.`); + if (match[2] !== 'value') add(issues, 'TEMPLATE_PROPERTY_UNSUPPORTED', `/project/actions/${index}/${field}`, `Component template property "${match[2]}" is unsupported; use value.`); + } + for (const match of value.matchAll(/\{\{variables\.([^.}]+)\}\}/g)) + if (!Object.prototype.hasOwnProperty.call(variables, match[1])) add(issues, 'DANGLING_VARIABLE_REFERENCE', `/project/actions/${index}/${field}`, `Variable "${match[1]}" does not exist.`); + if (/\{\{variables\.[^}]*\.[^}]*\}\}/.test(value)) add(issues, 'TEMPLATE_MALFORMED', `/project/actions/${index}/${field}`, 'Variable template names cannot contain dots.'); + }); + if (Array.isArray(action.responseMapping) && action.responseMapping.length > 0) + add(issues, 'RESPONSE_MAPPING_DEPRECATED', `/project/actions/${index}/responseMapping`, 'action.responseMapping is deprecated and not executed; use project.bindings.', 'warning'); + }); + Object.entries(variables).forEach(([name, variable]) => { + if (Object.prototype.hasOwnProperty.call(variable, 'defaultValue') && !valueMatchesType(variable.defaultValue, variable.type)) + add(issues, 'VARIABLE_DEFAULT_TYPE_MISMATCH', `/project/variables/${name}/defaultValue`, `Default value must match declared type "${variable.type}" or be null.`); + }); + if (project.settings.defaultPageId !== undefined && !new Set(pages.map((page) => page.id)).has(project.settings.defaultPageId)) + add(issues, 'DANGLING_DEFAULT_PAGE', '/project/settings/defaultPageId', `Default page "${project.settings.defaultPageId}" does not exist.`); + + // Keep this index live: it also makes duplicate-ID intent explicit for future nested references. + void componentIds; + return issues; +} -/** - * Validates `doc` against the Conductor project JSON schema. - * - * Returns `{ valid: true }` on success, or `{ valid: false, errors }` with a - * structured list of every schema violation found. - */ export function validateProjectDocument(doc: unknown): ValidationResult { const validate = getValidator(); - const ok = validate(doc); - - if (ok) return { valid: true }; - - const errors: ValidationError[] = (validate.errors ?? []).map((e) => ({ - path: e.instancePath || '(root)', - message: e.message ?? 'Unknown validation error', - })); - - return { valid: false, errors }; + if (!validate(doc)) { + const errors = (validate.errors ?? []).map((error) => ({ + code: 'SCHEMA_INVALID', path: schemaPath(error), + message: error.message ?? 'Schema validation failed.', severity: 'error' as const, + })); + return { valid: false, errors, warnings: [] }; + } + const issues = semanticIssues(doc as JsonObject); + const errors = issues.filter((issue) => issue.severity === 'error'); + const warnings = issues.filter((issue) => issue.severity === 'warning'); + return { valid: errors.length === 0, errors, warnings }; } diff --git a/backend/src/routes/executions.ts b/backend/src/routes/executions.ts new file mode 100644 index 0000000..5875e49 --- /dev/null +++ b/backend/src/routes/executions.ts @@ -0,0 +1,6 @@ +import { Router, Request, Response } from 'express'; +import { clearExecutions, listExecutions } from '../db/executions'; +const router=Router(); +router.get('/', (req: Request,res: Response) => res.json({executions:listExecutions(req.query)})); +router.delete('/', (_req: Request,res: Response) => { clearExecutions(); res.status(204).send(); }); +export default router; diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 6332f5c..4899674 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -5,10 +5,34 @@ import { createProject, updateProject, deleteProject, + defaultProjectDocument, } from '../db/projects'; +import { validateProjectDocument, ValidationIssue } from '../lib/validateProject'; const router = Router(); +function validationFailure(res: Response, errors: ValidationIssue[], warnings: ValidationIssue[] = []): void { + res.status(422).json({ + code: 'PROJECT_VALIDATION_FAILED', + message: 'Project document is invalid.', + errors, + warnings, + }); +} + +function parseAndValidateProjectJson(value: unknown, fallbackName?: string): { json: string; warnings: ValidationIssue[] } | { error: string } | { issues: ValidationIssue[]; warnings: ValidationIssue[] } { + let doc: unknown; + if (value === undefined && fallbackName !== undefined) doc = defaultProjectDocument(fallbackName); + else { + if (typeof value !== 'string') return { error: '`project_json` is required and must be a JSON string.' }; + try { doc = JSON.parse(value); } + catch { return { error: '`project_json` must contain valid JSON.' }; } + } + const result = validateProjectDocument(doc); + if (!result.valid) return { issues: result.errors, warnings: result.warnings }; + return { json: JSON.stringify(doc), warnings: result.warnings }; +} + // GET /api/projects — list all projects router.get('/', (_req: Request, res: Response) => { const projects = listProjects(); @@ -24,10 +48,14 @@ router.post('/', (req: Request, res: Response) => { return; } + const checked = parseAndValidateProjectJson(project_json, name.trim()); + if ('error' in checked) { res.status(400).json({ code: 'PROJECT_JSON_INVALID', message: checked.error }); return; } + if ('issues' in checked) { validationFailure(res, checked.issues, checked.warnings); return; } + const project = createProject({ name: name.trim(), description: typeof description === 'string' ? description : undefined, - project_json: typeof project_json === 'string' ? project_json : undefined, + project_json: checked.json, }); res.status(201).json(project); @@ -47,7 +75,12 @@ router.get('/:id', (req: Request, res: Response) => { return; } - res.json(project); + const checked = parseAndValidateProjectJson(project.project_json); + if ('error' in checked || 'issues' in checked) { + res.status(409).json({ code: 'STORED_PROJECT_INVALID', message: 'Stored project is invalid and was not loaded.', errors: 'issues' in checked ? checked.issues : [] }); + return; + } + res.json({ ...project, validation_warnings: checked.warnings }); }); // PUT /api/projects/:id — update a project @@ -59,10 +92,15 @@ router.put('/:id', (req: Request, res: Response) => { } const { name, description, project_json } = req.body as Record; + const existing = getProjectById(id); + if (!existing) { res.status(404).json({ code: 'PROJECT_NOT_FOUND', message: 'Project not found' }); return; } + const checked = parseAndValidateProjectJson(project_json ?? existing.project_json); + if ('error' in checked) { res.status(400).json({ code: 'PROJECT_JSON_INVALID', message: checked.error }); return; } + if ('issues' in checked) { validationFailure(res, checked.issues, checked.warnings); return; } const project = updateProject(id, { name: typeof name === 'string' ? name.trim() : undefined, description: typeof description === 'string' ? description : undefined, - project_json: typeof project_json === 'string' ? project_json : undefined, + project_json: checked.json, }); if (!project) { diff --git a/backend/src/routes/proxy.ts b/backend/src/routes/proxy.ts index 90e8450..a0d4720 100644 --- a/backend/src/routes/proxy.ts +++ b/backend/src/routes/proxy.ts @@ -19,7 +19,12 @@ import { Router, Request, Response } from 'express'; import { resolveSecret } from '../db/secrets'; -import { applyAuthentication, AuthenticationError, redactCredentialValues, sanitizeUrlForOutput } from '../lib/authentication'; +import { applyAuthentication, AuthenticationError, redactCredentialValues } from '../lib/authentication'; +import { MAX_REQUEST_BODY_BYTES, ProxyPolicyError, sanitizeUrl, validateRequestHeaders } from '../lib/proxyPolicy'; +import { requestWithPolicy } from '../lib/proxyTransport'; +import { parseResponseBody } from '../lib/responseBody'; +import { safeProxyFailureLog } from '../lib/proxyLogging'; +import { recordExecution, type ExecutionOutcome } from '../db/executions'; const router = Router(); @@ -32,23 +37,6 @@ const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); * Omit hop-by-hop headers and any that could leak internal infrastructure * details (e.g. X-Powered-By, Server). */ -const FORWARDED_RESPONSE_HEADERS = new Set([ - 'content-type', - 'content-length', - 'content-encoding', - 'cache-control', - 'etag', - 'last-modified', - 'x-request-id', - 'x-correlation-id', - 'x-ratelimit-limit', - 'x-ratelimit-remaining', - 'x-ratelimit-reset', -]); - -/** Request timeout in milliseconds. */ -const REQUEST_TIMEOUT_MS = 30_000; - // ── Types ───────────────────────────────────────────────────────────────────── interface RestActionInput { @@ -63,6 +51,8 @@ interface RestActionInput { bodyTemplate?: string; authenticationType?: string; secretReferenceId?: string; + projectId?: string; + projectName?: string; } interface ProxySuccessResponse { @@ -123,52 +113,35 @@ async function executeRequest(action: RestActionInput): Promise { const urlTemplate = action.url ?? ''; const pathParameters = action.pathParameters ?? {}; const queryParameters = { ...(action.queryParameters ?? {}) }; - const headers = { ...(action.headers ?? {}) }; + const headers = validateRequestHeaders({ ...(action.headers ?? {}) }); const bodyTemplate = action.bodyTemplate ?? ''; const sensitiveValues = applyAuthentication(action, headers, queryParameters, resolveSecret); + // Treat every query value as sensitive in observable output, regardless of + // whether it came from a stored credential or a runtime template. + sensitiveValues.push(...Object.values(queryParameters).filter(Boolean)); // Build URL const finalUrl = buildUrl(urlTemplate, pathParameters, queryParameters); - // Build request init - const init: RequestInit = { - method, - headers, - // Attach body only for methods that semantically support it - body: method !== 'GET' && method !== 'DELETE' && bodyTemplate.trim() - ? bodyTemplate - : undefined, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }; + const requestBody = method !== 'GET' && method !== 'DELETE' && bodyTemplate.trim() ? bodyTemplate : undefined; + if (requestBody && Buffer.byteLength(requestBody, 'utf8') > MAX_REQUEST_BODY_BYTES) throw new ProxyPolicyError('PROXY_REQUEST_TOO_LARGE', 'Request body exceeded the 1 MiB limit.', 413); const startMs = Date.now(); - const upstream = await fetch(finalUrl, init); + const upstream = await requestWithPolicy(finalUrl, method, headers, requestBody); const durationMs = Date.now() - startMs; // Collect safe response headers const responseHeaders: Record = {}; - upstream.headers.forEach((value, name) => { - if (FORWARDED_RESPONSE_HEADERS.has(name.toLowerCase())) { - responseHeaders[name.toLowerCase()] = value; - } - }); + for (const [name,value] of Object.entries(upstream.headers)) if (name !== 'location') responseHeaders[name] = value; // Parse body: try JSON first, fall back to text - const contentType = upstream.headers.get('content-type') ?? ''; + const contentType = upstream.headers['content-type'] ?? ''; let body: unknown; - if (contentType.includes('application/json')) { - try { - body = await upstream.json(); - } catch { - body = await upstream.text(); - } - } else { - body = await upstream.text(); - } + body = parseResponseBody(upstream.body, contentType); return { - ok: upstream.ok, + ok: upstream.status >= 200 && upstream.status < 300, status: upstream.status, statusText: upstream.statusText, headers: responseHeaders, @@ -210,31 +183,31 @@ router.post('/', async (req: Request, res: Response): Promise => { // ── Execution ────────────────────────────────────────────────────────────── let result: ProxyResponse; + const startedAt = Date.now(); + const historyBase = { projectId: action.projectId, projectName: action.projectName, actionId: action.id, actionName: action.name, method, url: sanitizeUrl(action.url) }; try { result = await executeRequest({ ...action, method }); } catch (err) { if (err instanceof AuthenticationError) { + recordExecution({...historyBase,durationMs:Date.now()-startedAt,outcome:'policy_rejected',errorCode:err.code,errorMessage:err.message}); res.status(err.status).json({ code: err.code, error: err.message }); return; } - // Network-level failure (DNS, ECONNREFUSED, timeout, etc.) - const message = err instanceof Error ? err.message : String(err); - const isTimeout = err instanceof Error && err.name === 'TimeoutError'; - - console.error('[proxy] Network error executing action:', { - url: sanitizeUrlForOutput(action.url), - method, - error: message, - }); - - res.status(502).json({ - error: isTimeout - ? `Request to "${sanitizeUrlForOutput(action.url)}" timed out after ${REQUEST_TIMEOUT_MS / 1000}s.` - : `Network error reaching "${sanitizeUrlForOutput(action.url)}": ${message}`, - }); + const policy = err instanceof ProxyPolicyError ? err : new ProxyPolicyError('PROXY_NETWORK_ERROR','The upstream destination could not be reached.',502); + let outcome: ExecutionOutcome = 'network_error'; + if (policy.code === 'PROXY_TIMEOUT') outcome='timeout'; else if (policy.code.includes('TOO_LARGE')) outcome='size_limited'; else if (policy.status === 400 || policy.code.includes('FORBIDDEN')) outcome='policy_rejected'; + recordExecution({...historyBase,durationMs:Date.now()-startedAt,outcome,errorCode:policy.code,errorMessage:policy.message}); + console.error('[proxy]', safeProxyFailureLog(method, policy.code, action.id)); + res.status(policy.status).json({ code: policy.code, error: policy.message }); return; } + const redactedBody = result.body; + const previewText = typeof redactedBody === 'string' ? redactedBody : JSON.stringify(redactedBody); + const preview = previewText.slice(0,2048); + const responseBytes = Buffer.byteLength(previewText,'utf8'); + recordExecution({...historyBase,status:result.status,durationMs:result.durationMs,outcome:result.ok?'success':'upstream_error',preview,responseBytes,previewTruncated:responseBytes>Buffer.byteLength(preview,'utf8'),contentType:result.headers['content-type']}); + // Non-2xx responses from the upstream are not backend errors; return them // faithfully so the client can display the actual target status. res.status(200).json(result); diff --git a/backend/src/routes/validate.ts b/backend/src/routes/validate.ts index 11a3846..3f8bb19 100644 --- a/backend/src/routes/validate.ts +++ b/backend/src/routes/validate.ts @@ -8,9 +8,9 @@ const router = Router(); // Body: any JSON value (expected to be a Conductor project definition document) // // Returns: -// 200 { valid: true } +// 200 { valid: true, errors: [], warnings: [...] } // — document satisfies the schema -// 422 { valid: false, errors: [{ path, message }, ...] } +// 422 { valid: false, errors: [...], warnings: [...] } // — document is syntactically valid JSON but fails schema validation // 400 { error: string } // — body is not parseable JSON (Express json() middleware rejects it @@ -36,11 +36,11 @@ router.post('/', (req: Request, res: Response) => { } if (result.valid) { - res.status(200).json({ valid: true }); + res.status(200).json(result); return; } - res.status(422).json({ valid: false, errors: result.errors }); + res.status(422).json(result); }); export default router; diff --git a/docker-compose.yml b/docker-compose.yml index 33a1cb2..cf2f11f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - PORT=4000 - NODE_ENV=development - CONDUCTOR_SECRET_KEY=${CONDUCTOR_SECRET_KEY:-} + # Optional comma-separated exact origins for narrowly approved private APIs. + - CONDUCTOR_PROXY_INTERNAL_ORIGINS=${CONDUCTOR_PROXY_INTERNAL_ORIGINS:-} # Data directory is mounted so the SQLite file survives container restarts. # shared/ is mounted read-only at /shared so the backend can resolve the # schema via path.resolve(__dirname, '../../../shared/schemas/...'). diff --git a/docs/PROXY_SECURITY.md b/docs/PROXY_SECURITY.md new file mode 100644 index 0000000..5635956 --- /dev/null +++ b/docs/PROXY_SECURITY.md @@ -0,0 +1,31 @@ +# Proxy Security and Execution-History Policy + +## Threat model + +The REST proxy accepts destinations and request templates from project authors. Treat those values, DNS answers, redirects, upstream headers, bodies, and errors as untrusted. The proxy must prevent access to the Conductor host and private infrastructure, credential forwarding to a different origin, request smuggling, unbounded memory use, and disclosure through responses, logs, or execution history. + +Conductor v0.1.0 is single-user software. This policy does not provide tenant isolation, RBAC, malware inspection, TLS interception, or protection from an already-compromised backend process. + +## Destination contract + +- Only `http:` and `https:` URLs without embedded credentials are accepted. +- Public destinations are allowed after every DNS result is classified. Loopback, unspecified, private, carrier-grade NAT, link-local, multicast, documentation, benchmark, reserved, and metadata-service address ranges are denied. A hostname is denied if any returned address is unsafe or DNS returns no usable address. +- Connections use a validated address rather than performing an unchecked second DNS lookup. +- Redirects are followed manually, resolved against the prior URL, and fully revalidated. At most three redirects are allowed. Authentication and author-supplied sensitive headers are removed whenever the redirect changes origin. +- Internal exceptions are server-controlled exact origins in `CONDUCTOR_PROXY_INTERNAL_ORIGINS`, separated by commas (for example `https://api.internal.example:8443`). Schemes and effective ports must match. Paths, credentials, IP literals, and wildcards are invalid; subdomains are not inferred. Exceptions never enter project JSON. +- Public hosts do not require an administrative hostname allowlist in the MVP. There are no wildcard rules. Any explicit non-default port is allowed only when its resolved addresses remain safe; deployments can constrain egress further at the network layer. + +## Request and response controls + +- Methods remain GET, POST, PUT, PATCH, and DELETE. +- Author-supplied request headers are limited to `Accept`, `Accept-Language`, `Content-Type`, `If-Match`, `If-None-Match`, `Idempotency-Key`, and names beginning `X-`. `Authorization` and API-key headers may only be injected from server-side secrets. Cookie, proxy, forwarding, host, connection, transfer, upgrade, content-length, and other hop-by-hop headers are prohibited. +- Request bodies are limited to 1 MiB after UTF-8 encoding. Proxy API JSON is limited to 2 MiB. +- Response bodies are limited to 5 MiB while streaming. Execution-history previews are independently limited to 2 KiB. +- The existing fixed 30-second total timeout is retained. There is no canonical per-action timeout. +- Network, DNS, TLS, timeout, malformed-response, policy, and limit failures use stable safe codes and messages. They do not include stack traces, DNS answers, private addresses, or raw transport errors. + +## Execution-history contract + +SQLite stores the most recent 1,000 executions globally. Each record contains its timestamp, optional project identity supplied by the runtime, action ID/name, method, sanitized URL (query values and embedded credentials redacted), upstream status when available, duration, outcome, safe error code/message, response byte count, truncation flag, content type, and a redacted preview no larger than 2 KiB. Oldest records are deleted after insertion. + +History persists across restart. `GET /api/executions` supports project, action, outcome, and status filters with a maximum page size of 100. `DELETE /api/executions` clears all history and returns no records. History never stores request or response headers, cookies, credential values, sensitive query values, stack traces, DNS results, resolved addresses, or secret-resolution details. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..01a2d08 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,50 @@ +# Project Validation + +Conductor uses `shared/schemas/conductor-project.schema.json` plus backend semantic validation as the canonical project-document gate. The same validator is used by JSON Apply, create, update, save, stored-project load checks, and frontend diagnostics. + +## Compatibility policy + +- This release accepts exactly `schemaVersion: "0.1.0"`. +- Other versions fail with `SCHEMA_VERSION_UNSUPPORTED`; Conductor does not guess, coerce, or migrate them. +- Invalid stored JSON is left untouched and returned as `STORED_PROJECT_INVALID` when loaded. There is no automatic v0.1.0 migration. +- Missing properties remain missing and explicit JSON values—including `null`, empty arrays, and empty objects—are preserved. Validation does not apply schema defaults or mutate documents. + +## Result contract + +Validation returns `valid`, `errors`, and `warnings`. Every issue has a stable `code`, JSON Pointer-style `path`, actionable `message`, and `severity`. + +Errors block Apply and persistence. Warnings identify compatibility-only shapes that remain readable but are not executed: `ComponentEvent.inputMap`, non-empty deprecated `action.responseMapping`, component-level binding references, and `Binding.transform`. + +Project create/update failures use: + +```json +{ + "code": "PROJECT_VALIDATION_FAILED", + "message": "Project document is invalid.", + "errors": [], + "warnings": [] +} +``` + +No database write occurs until parsing, schema validation, and semantic validation all succeed. The database access layer repeats the validation assertion so future callers cannot bypass the route gate. + +## Semantic rules + +Blocking checks cover: + +- unique page, action, binding, and project-wide component identities/names; +- supported page/component events and existing action references; +- existing action, component, variable, binding, and default-page references; +- runtime-supported action-response source paths, triggers, and component target properties; +- variable default-value type compatibility, while preserving explicit `null`; +- component-specific options, configured values, Table columns, and value types; +- balanced templates, supported component value templates, and declared variables; +- exact schema-version compatibility. + +Path parameters follow one contract: frontend runtime interpolation resolves `{{components..value}}` and `{{variables.}}` inside `pathParameters` values; the backend then substitutes those rendered values into bare URL placeholders such as `{{itemId}}`. + +## UI and recovery + +Visual Editor, Actions & Bindings, JSON Editor, and Preview display the shared result. A validation/network failure leaves canonical state and JSON Editor draft text unchanged. Save remains dirty after failure. Loading an invalid stored document leaves the currently open project intact. New and Load ask for confirmation before discarding unsaved edits. + +Project deletion remains backend-only for v0.1.0. A dedicated Projects screen and deletion control are deferred because project browsing is not yet an implemented MVP surface; the existing API continues to require an explicit project ID. diff --git a/examples/project-definitions/valid-button-binding.json b/examples/project-definitions/valid-button-binding.json index 163a564..2bf868f 100644 --- a/examples/project-definitions/valid-button-binding.json +++ b/examples/project-definitions/valid-button-binding.json @@ -43,9 +43,7 @@ "disabled": false }, "events": [], - "bindings": [ - { "bindingId": "binding_action_to_viewer" } - ] + "bindings": [] } ], "events": [] @@ -67,8 +65,7 @@ }, "pathParameters": {}, "bodyTemplate": "", - "authenticationType": "anonymous", - "responseMapping": [] + "authenticationType": "anonymous" } ], diff --git a/examples/project-definitions/valid-dropdown-basic.json b/examples/project-definitions/valid-dropdown-basic.json index f8e620a..b9cd0c4 100644 --- a/examples/project-definitions/valid-dropdown-basic.json +++ b/examples/project-definitions/valid-dropdown-basic.json @@ -75,9 +75,7 @@ "disabled": false }, "events": [], - "bindings": [ - { "bindingId": "bind_body_to_viewer" } - ] + "bindings": [] } ], "events": [] @@ -100,8 +98,7 @@ }, "pathParameters": {}, "bodyTemplate": "", - "authenticationType": "anonymous", - "responseMapping": [] + "authenticationType": "anonymous" } ], diff --git a/examples/project-definitions/valid-dropdown-response-mapping.json b/examples/project-definitions/valid-dropdown-response-mapping.json index c2f511d..0e256f4 100644 --- a/examples/project-definitions/valid-dropdown-response-mapping.json +++ b/examples/project-definitions/valid-dropdown-response-mapping.json @@ -115,8 +115,7 @@ "queryParameters": {}, "pathParameters": {}, "bodyTemplate": "{\"options\":[{\"label\":\"Host One\",\"value\":\"host1\"},{\"label\":\"Host Two\",\"value\":\"host2\"},{\"label\":\"Host Three\",\"value\":\"host3\"}]}", - "authenticationType": "anonymous", - "responseMapping": [] + "authenticationType": "anonymous" }, { "id": "action_use_selection", @@ -132,8 +131,7 @@ }, "pathParameters": {}, "bodyTemplate": "", - "authenticationType": "anonymous", - "responseMapping": [] + "authenticationType": "anonymous" } ], diff --git a/examples/project-definitions/valid-full.json b/examples/project-definitions/valid-full.json index 922122d..be0b8f3 100644 --- a/examples/project-definitions/valid-full.json +++ b/examples/project-definitions/valid-full.json @@ -82,12 +82,7 @@ "events": [ { "event": "onClick", - "actionId": "action_trigger_workflow", - "inputMap": { - "environment": "components.environmentDropdown.value", - "workflowId": "components.workflowIdInput.value", - "params": "components.paramsTextArea.value" - } + "actionId": "action_trigger_workflow" } ], "bindings": [] @@ -104,9 +99,7 @@ "disabled": false }, "events": [], - "bindings": [ - { "bindingId": "bind_status_to_panel" } - ] + "bindings": [] }, { "id": "cmp_results_viewer", @@ -120,9 +113,7 @@ "disabled": false }, "events": [], - "bindings": [ - { "bindingId": "bind_response_to_viewer" } - ] + "bindings": [] } ], "events": [] @@ -152,16 +143,13 @@ ] }, "events": [], - "bindings": [ - { "bindingId": "bind_history_to_table" } - ] + "bindings": [] } ], "events": [ { "event": "onLoad", - "actionId": "action_list_executions", - "inputMap": {} + "actionId": "action_list_executions" } ] } @@ -179,27 +167,13 @@ "Accept": "application/json" }, "queryParameters": { - "environment": "{{environment}}" + "environment": "{{components.environmentDropdown.value}}" }, "pathParameters": { - "workflowId": "{{workflowId}}" + "workflowId": "{{components.workflowIdInput.value}}" }, - "bodyTemplate": "{\"params\": {{params}}}", - "authenticationType": "bearerToken", - "responseMapping": [ - { - "source": "status", - "target": "variables.lastRunStatus" - }, - { - "source": "data", - "target": "components.resultsViewer.data" - }, - { - "source": "message", - "target": "components.statusPanel.message" - } - ] + "bodyTemplate": "{\"params\": {{components.paramsTextArea.value}}}", + "authenticationType": "bearerToken" }, { "id": "action_list_executions", @@ -213,34 +187,28 @@ "queryParameters": {}, "pathParameters": {}, "bodyTemplate": "", - "authenticationType": "bearerToken", - "responseMapping": [ - { - "source": "executions", - "target": "components.historyTable.data" - } - ] + "authenticationType": "bearerToken" } ], "bindings": [ { "id": "bind_status_to_panel", - "source": "variables.lastRunStatus", - "target": "components.statusPanel.message", - "trigger": "onChange" + "source": "actions.action_trigger_workflow.response.body.status", + "target": "components.statusPanel.value", + "trigger": "onSuccess" }, { "id": "bind_response_to_viewer", "source": "actions.action_trigger_workflow.response", - "target": "components.resultsViewer.data", - "trigger": "onChange" + "target": "components.resultsViewer.value", + "trigger": "onSuccess" }, { "id": "bind_history_to_table", "source": "actions.action_list_executions.response", - "target": "components.historyTable.data", - "trigger": "onChange" + "target": "components.historyTable.rows", + "trigger": "onSuccess" } ], diff --git a/examples/project-definitions/valid-input-request-binding.json b/examples/project-definitions/valid-input-request-binding.json index c562f84..bfede56 100644 --- a/examples/project-definitions/valid-input-request-binding.json +++ b/examples/project-definitions/valid-input-request-binding.json @@ -59,9 +59,7 @@ "disabled": false }, "events": [], - "bindings": [ - { "bindingId": "binding_action_to_viewer" } - ] + "bindings": [] } ], "events": [] @@ -84,8 +82,7 @@ }, "pathParameters": {}, "bodyTemplate": "", - "authenticationType": "anonymous", - "responseMapping": [] + "authenticationType": "anonymous" } ], diff --git a/examples/project-definitions/valid-response-mapping-basic.json b/examples/project-definitions/valid-response-mapping-basic.json index 4ea4980..63741dc 100644 --- a/examples/project-definitions/valid-response-mapping-basic.json +++ b/examples/project-definitions/valid-response-mapping-basic.json @@ -79,8 +79,7 @@ }, "pathParameters": {}, "bodyTemplate": "", - "authenticationType": "anonymous", - "responseMapping": [] + "authenticationType": "anonymous" } ], diff --git a/examples/project-definitions/valid-rest-actions.json b/examples/project-definitions/valid-rest-actions.json index 4c15cea..d56a46f 100644 --- a/examples/project-definitions/valid-rest-actions.json +++ b/examples/project-definitions/valid-rest-actions.json @@ -31,8 +31,7 @@ }, "pathParameters": {}, "bodyTemplate": "", - "authenticationType": "anonymous", - "responseMapping": [] + "authenticationType": "anonymous" }, { @@ -48,8 +47,7 @@ "queryParameters": {}, "pathParameters": {}, "bodyTemplate": "{\"name\": \"{{itemName}}\", \"category\": \"{{category}}\"}", - "authenticationType": "bearerToken", - "responseMapping": [] + "authenticationType": "bearerToken" }, { @@ -66,8 +64,7 @@ }, "pathParameters": {}, "bodyTemplate": "", - "authenticationType": "basicAuth", - "responseMapping": [] + "authenticationType": "basicAuth" }, { @@ -86,8 +83,7 @@ "datasetId": "{{datasetId}}" }, "bodyTemplate": "", - "authenticationType": "apiKeyHeader", - "responseMapping": [] + "authenticationType": "apiKeyHeader" }, { @@ -105,8 +101,7 @@ }, "pathParameters": {}, "bodyTemplate": "", - "authenticationType": "apiKeyQueryParameter", - "responseMapping": [] + "authenticationType": "apiKeyQueryParameter" }, { @@ -124,8 +119,7 @@ "itemId": "{{itemId}}" }, "bodyTemplate": "{\"status\": \"{{newStatus}}\"}", - "authenticationType": "bearerToken", - "responseMapping": [] + "authenticationType": "bearerToken" }, { @@ -142,8 +136,7 @@ "itemId": "{{itemId}}" }, "bodyTemplate": "", - "authenticationType": "bearerToken", - "responseMapping": [] + "authenticationType": "bearerToken" } ], diff --git a/examples/project-definitions/valid-table-basic.json b/examples/project-definitions/valid-table-basic.json index 534ab28..6632dfb 100644 --- a/examples/project-definitions/valid-table-basic.json +++ b/examples/project-definitions/valid-table-basic.json @@ -1,5 +1,5 @@ { - "schemaVersion": "1.0.0", + "schemaVersion": "0.1.0", "project": { "id": "table-basic-example", "name": "Table Basic Example", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ca0f6c..1657c92 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import JsonEditor from './components/JsonEditor/JsonEditor'; import Preview from './components/Preview/Preview'; import ActionInspector from './components/ActionInspector/ActionInspector'; import { ProjectProvider } from './context/ProjectContext'; +import ExecutionHistory from './components/ExecutionHistory/ExecutionHistory'; // Placeholder panels — replaced with real implementations in later steps function PlaceholderPanel({ title, description }: { title: string; description: string }): React.ReactElement { @@ -29,6 +30,7 @@ function AppContent(): React.ReactElement { if (activeItem === 'json-editor') return ; if (activeItem === 'preview') return ; if (activeItem === 'inspector') return ; + if (activeItem === 'history') return ; return null; } diff --git a/frontend/src/api/executionsApi.ts b/frontend/src/api/executionsApi.ts new file mode 100644 index 0000000..12ec640 --- /dev/null +++ b/frontend/src/api/executionsApi.ts @@ -0,0 +1,14 @@ +export type ExecutionRecord = { + id: number; createdAt: string; projectId: string|null; projectName: string|null; + actionId: string|null; actionName: string|null; method: string; sanitizedUrl: string; + status: number|null; durationMs: number; outcome: string; errorCode: string|null; + errorMessage: string|null; preview: string|null; responseBytes: number|null; + previewTruncated: number; contentType: string|null; +}; + +export async function listExecutions(filters: {actionId?:string;outcome?:string;status?:string} = {}): Promise { + const query=new URLSearchParams(); Object.entries(filters).forEach(([key,value]) => { if(value) query.set(key,value); }); + const response=await fetch(`/api/executions?${query}`); if(!response.ok) throw new Error('Could not load execution history.'); + return ((await response.json()) as {executions:ExecutionRecord[]}).executions; +} +export async function clearExecutions(): Promise { const response=await fetch('/api/executions',{method:'DELETE'}); if(!response.ok) throw new Error('Could not clear execution history.'); } diff --git a/frontend/src/api/projectsApi.ts b/frontend/src/api/projectsApi.ts index d52818a..a7171d0 100644 --- a/frontend/src/api/projectsApi.ts +++ b/frontend/src/api/projectsApi.ts @@ -12,8 +12,9 @@ async function handleResponse(res: Response): Promise { if (!res.ok) { let message = `HTTP ${res.status}`; try { - const body = await res.json() as { error?: string }; - if (body.error) message = body.error; + const body = await res.json() as { error?: string; message?: string; errors?: Array<{ path: string; message: string }> }; + if (body.message || body.error) message = body.message ?? body.error ?? message; + if (body.errors?.length) message += ` ${body.errors.slice(0, 3).map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`; } catch { // ignore parse failure — use status text message = res.statusText || message; diff --git a/frontend/src/api/proxyApi.ts b/frontend/src/api/proxyApi.ts index 91ad802..36acce1 100644 --- a/frontend/src/api/proxyApi.ts +++ b/frontend/src/api/proxyApi.ts @@ -28,11 +28,11 @@ export type ProxyResponse = { * Throws an Error with a human-readable message on network failure or a * non-200 response from the proxy itself (e.g. 400 validation error). */ -export async function executeAction(action: RestAction): Promise { +export async function executeAction(action: RestAction, project?: {id:string;name:string}): Promise { const res = await fetch('/api/proxy/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(action), + body: JSON.stringify({...action,projectId:project?.id,projectName:project?.name}), }); if (!res.ok) { diff --git a/frontend/src/api/validationApi.test.ts b/frontend/src/api/validationApi.test.ts new file mode 100644 index 0000000..e984fa4 --- /dev/null +++ b/frontend/src/api/validationApi.test.ts @@ -0,0 +1,24 @@ +import { validateProject } from './validationApi'; + +const fetchMock = jest.fn(); +beforeEach(() => { fetchMock.mockReset(); global.fetch = fetchMock; }); + +test.each([200, 422])('returns structured validation results for HTTP %s', async (status) => { + const body = status === 200 + ? { valid: true, errors: [], warnings: [] } + : { valid: false, errors: [{ code: 'SCHEMA_INVALID', path: '/project', message: 'required', severity: 'error' }], warnings: [] }; + fetchMock.mockResolvedValue({ status, json: async () => body }); + await expect(validateProject({})).resolves.toEqual(body); +}); + +test('surfaces the backend message without discarding the caller draft', async () => { + fetchMock.mockResolvedValue({ status: 500, json: async () => ({ message: 'Validation unavailable' }) }); + await expect(validateProject({ draft: true })).rejects.toThrow('Validation unavailable'); + expect(fetchMock).toHaveBeenCalledWith('/api/projects/validate', expect.objectContaining({ body: '{"draft":true}' })); +}); + +test('normalizes an older invalid response that omits warnings', async () => { + const error = { code: 'SCHEMA_VERSION_UNSUPPORTED', path: '/schemaVersion', message: 'unsupported', severity: 'error' }; + fetchMock.mockResolvedValue({ status: 422, json: async () => ({ valid: false, errors: [error] }) }); + await expect(validateProject({})).resolves.toEqual({ valid: false, errors: [error], warnings: [] }); +}); diff --git a/frontend/src/api/validationApi.ts b/frontend/src/api/validationApi.ts new file mode 100644 index 0000000..139700e --- /dev/null +++ b/frontend/src/api/validationApi.ts @@ -0,0 +1,34 @@ +import type { ProjectDocument } from '../types/project'; + +export type ValidationSeverity = 'error' | 'warning'; +export type ValidationIssue = { + code: string; + path: string; + message: string; + severity: ValidationSeverity; +}; +export type ValidationResult = { + valid: boolean; + errors: ValidationIssue[]; + warnings: ValidationIssue[]; +}; + +export async function validateProject(doc: unknown): Promise { + const response = await fetch('/api/projects/validate', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(doc), + }); + if (response.status === 200 || response.status === 422) { + const body = await response.json() as Partial; + return { + valid: body.valid === true, + errors: Array.isArray(body.errors) ? body.errors : [], + warnings: Array.isArray(body.warnings) ? body.warnings : [], + }; + } + const body = await response.json().catch(() => ({})) as { message?: string; error?: string }; + throw new Error(body.message ?? body.error ?? `Validation service returned HTTP ${response.status}.`); +} + +export function asProjectDocument(value: unknown): ProjectDocument { + return value as ProjectDocument; +} diff --git a/frontend/src/components/ActionInspector/ActionInspector.tsx b/frontend/src/components/ActionInspector/ActionInspector.tsx index 0cdf312..58c0ef8 100644 --- a/frontend/src/components/ActionInspector/ActionInspector.tsx +++ b/frontend/src/components/ActionInspector/ActionInspector.tsx @@ -55,6 +55,7 @@ import VariableEditor from './VariableEditor'; import SecretManager from './SecretManager'; import { listSecrets } from '../../api/secretsApi'; import type { SecretMetadata } from '../../api/secretsApi'; +import ValidationSummary from '../ValidationSummary'; import { createResponseBinding, findVariableReferences, @@ -172,32 +173,6 @@ function computeDiagnostics( for (const token of tokens) { if (token.namespace === 'variables') { - // Variable token in pathParameters — not supported in Step 18.1. - // Warn regardless of whether the variable is declared, because the - // runtime does not interpolate pathParameters values at all. - if (token.location.startsWith('pathParameters.')) { - add( - diagnosticKey, - 'warn', - `Template "${token.raw}" at ${token.location} uses a variable placeholder ` + - `in a path-parameter value. Variable interpolation is not supported in ` + - `pathParameters in Step 18.1. ` + - `Supported locations: url, headers, queryParameters, bodyTemplate.`, - ); - // Still check declaration so the user knows if the variable is also missing, - // but use a separate, clearly scoped message. - if (!declaredVariableNames.has(token.name)) { - add( - diagnosticKey, - 'warn', - `Template "${token.raw}" at ${token.location} also references variable ` + - `"${token.name}" which is not declared in project.variables.`, - ); - } - continue; - } - - // Variable token in a supported location — check declaration (Step 18.1) if (!declaredVariableNames.has(token.name)) { add( diagnosticKey, @@ -1034,6 +1009,7 @@ function ActionInspector(): React.ReactElement { return (
+ {/* ── Page header ── */}
Actions & Bindings
diff --git a/frontend/src/components/ActionInspector/ResponseBindingEditor.tsx b/frontend/src/components/ActionInspector/ResponseBindingEditor.tsx index 2a0e989..bc4f4cb 100644 --- a/frontend/src/components/ActionInspector/ResponseBindingEditor.tsx +++ b/frontend/src/components/ActionInspector/ResponseBindingEditor.tsx @@ -52,10 +52,11 @@ export default function ResponseBindingEditor({ binding, actions, components, va {isLegacyOnClick && This existing legacy trigger remains compatible. Choose On success to migrate it; new bindings cannot select On click.} - + {binding.transform !== undefined && ( +
+ This existing transform is retained for compatibility but is not executed. Remove it in the JSON Editor to clear the warning. +
+ )}
); diff --git a/frontend/src/components/ActionInspector/SecretManager.tsx b/frontend/src/components/ActionInspector/SecretManager.tsx index 910d21d..f52955b 100644 --- a/frontend/src/components/ActionInspector/SecretManager.tsx +++ b/frontend/src/components/ActionInspector/SecretManager.tsx @@ -26,7 +26,7 @@ export default function SecretManager({ secrets, actions, onChanged }: Props): R const refs = actions.filter((a) => a.secretReferenceId === secret.id); if (refs.length) { setError(`Cannot delete “${secret.name}”; referenced by ${refs.map((a) => a.name).join(', ')}.`); return; } if (!window.confirm(`Delete secret “${secret.name}”? This cannot be undone.`)) return; - try { await api.deleteSecret(secret.id); await onChanged(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } + try { await api.deleteSecret(secret.id); await onChanged(); setError(''); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }; return
Secrets
Encrypted server-side credentials. Stored values are never displayed after submission.
{secrets.length}
diff --git a/frontend/src/components/ExecutionHistory/ExecutionHistory.module.css b/frontend/src/components/ExecutionHistory/ExecutionHistory.module.css new file mode 100644 index 0000000..54b5db3 --- /dev/null +++ b/frontend/src/components/ExecutionHistory/ExecutionHistory.module.css @@ -0,0 +1 @@ +.heading,.summary,.filters{display:flex;gap:16px;align-items:center}.heading{justify-content:space-between}.heading h2{margin:0 0 6px}.heading p{margin:0;color:#57606a}.filters{flex-wrap:wrap;margin:20px 0}.filters label{display:grid;gap:4px;font-size:13px}.filters input,.filters select,.filters button,.heading button{padding:7px 9px}.list{display:grid;gap:12px}.card{border:1px solid #d0d7de;border-radius:6px;padding:14px;background:#fff}.summary{flex-wrap:wrap}.outcome{text-transform:capitalize;background:#ddf4ff;padding:2px 7px;border-radius:10px}.meta{display:grid;gap:6px;margin-top:10px;color:#57606a}.meta code{overflow-wrap:anywhere}.card pre{white-space:pre-wrap;max-height:240px;overflow:auto;background:#f6f8fa;padding:10px}.error{color:#cf222e} diff --git a/frontend/src/components/ExecutionHistory/ExecutionHistory.tsx b/frontend/src/components/ExecutionHistory/ExecutionHistory.tsx new file mode 100644 index 0000000..322c9ae --- /dev/null +++ b/frontend/src/components/ExecutionHistory/ExecutionHistory.tsx @@ -0,0 +1,22 @@ +import React, { useEffect, useState } from 'react'; +import { clearExecutions, listExecutions, type ExecutionRecord } from '../../api/executionsApi'; +import styles from './ExecutionHistory.module.css'; + +export default function ExecutionHistory(): React.ReactElement { + const [records,setRecords]=useState([]); const [action,setAction]=useState(''); const [outcome,setOutcome]=useState(''); const [status,setStatus]=useState(''); const [error,setError]=useState(''); + const load=React.useCallback(async()=>{try{setError('');setRecords(await listExecutions({actionId:action,outcome,status}));}catch(e){setError(e instanceof Error?e.message:'Could not load execution history.');}},[action,outcome,status]); + useEffect(()=>{void load();},[load]); + async function clear():Promise{if(!window.confirm('Clear all execution history? This cannot be undone.'))return;try{await clearExecutions();setRecords([]);}catch(e){setError(e instanceof Error?e.message:'Could not clear execution history.');}} + return
+

Execution History

Recent sanitized proxy executions. History persists across backend restarts.

+
+ {error&&

{error}

} + {!error&&records.length===0&&

No executions match these filters.

} +
{records.map(record=>
+
{record.actionName||record.actionId||'Unnamed action'}{record.outcome.replaceAll('_',' ')}{record.status??'—'}{record.durationMs} ms
+
{record.method} {record.sanitizedUrl}
+ {record.errorMessage&&

{record.errorCode}: {record.errorMessage}

} + {record.preview&&
Response preview ({record.responseBytes??0} bytes{record.previewTruncated?' — truncated':''})
{record.preview}
} +
)}
+
; +} diff --git a/frontend/src/components/JsonEditor/JsonEditor.tsx b/frontend/src/components/JsonEditor/JsonEditor.tsx index 237e429..ea9284e 100644 --- a/frontend/src/components/JsonEditor/JsonEditor.tsx +++ b/frontend/src/components/JsonEditor/JsonEditor.tsx @@ -3,42 +3,11 @@ import ProjectToolbar from '../ProjectToolbar/ProjectToolbar'; import { useProject } from '../../context/ProjectContext'; import type { ProjectDocument } from '../../types/project'; import styles from './JsonEditor.module.css'; +import { validateProject, type ValidationIssue } from '../../api/validationApi'; +import ValidationSummary from '../ValidationSummary'; // ── Schema validation via the backend endpoint ──────────────────────────────── -type ValidationError = { path: string; message: string }; -type ValidationResult = - | { valid: true } - | { valid: false; errors: ValidationError[] }; - -async function validateAgainstSchema(doc: unknown): Promise { - const res = await fetch('/api/projects/validate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(doc), - }); - - // 200 — valid document - if (res.status === 200) { - return res.json() as Promise; - } - - // 422 — document is syntactically valid JSON but fails schema validation. - // The body is { valid: false, errors: [...] } — return it as a result, not an error. - if (res.status === 422) { - return res.json() as Promise; - } - - // 400 — request body was not parseable JSON (should not happen here, but handle cleanly) - if (res.status === 400) { - const body = await res.json().catch(() => ({})) as { error?: string }; - throw new Error(body.error ?? 'Request was rejected by the validation endpoint (HTTP 400).'); - } - - // 500 or any other unexpected status — validation service is genuinely unavailable - throw new Error(`Validation service returned HTTP ${res.status}. Check server logs.`); -} - // ── Component ───────────────────────────────────────────────────────────────── function JsonEditor(): React.ReactElement { @@ -50,7 +19,8 @@ function JsonEditor(): React.ReactElement { const [text, setText] = useState(() => JSON.stringify(doc, null, 2)); const [syntaxError, setSyntaxError] = useState(null); - const [schemaErrors, setSchemaErrors] = useState([]); + const [schemaErrors, setSchemaErrors] = useState([]); + const [warnings, setWarnings] = useState([]); const [isValidating, setIsValidating] = useState(false); const [lastApplied, setLastApplied] = useState(null); @@ -67,6 +37,7 @@ function JsonEditor(): React.ReactElement { setText(serialised); setSyntaxError(null); setSchemaErrors([]); + setWarnings([]); setLastApplied(serialised); }, [doc]); @@ -76,6 +47,7 @@ function JsonEditor(): React.ReactElement { setText(e.target.value); setSyntaxError(null); // clear stale errors while typing setSchemaErrors([]); + setWarnings([]); }, []); // ── Apply: validate then push to context ───────────────────────────────── @@ -96,7 +68,8 @@ function JsonEditor(): React.ReactElement { // Step 2: schema validation via backend setIsValidating(true); try { - const result = await validateAgainstSchema(parsed); + const result = await validateProject(parsed); + setWarnings(result.warnings); if (!result.valid) { setSchemaErrors(result.errors); return; @@ -125,6 +98,7 @@ function JsonEditor(): React.ReactElement { setText(serialised); setSyntaxError(null); setSchemaErrors([]); + setWarnings([]); setLastApplied(serialised); userEditingRef.current = false; }, [doc]); @@ -137,6 +111,7 @@ function JsonEditor(): React.ReactElement {
{/* ── Project toolbar (shared with Visual Editor) ───────────── */} + {/* ── Editor header ─────────────────────────────────────────── */}
@@ -222,6 +197,20 @@ function JsonEditor(): React.ReactElement {
)} + {warnings.length > 0 && ( +
+
Compatibility warnings ({warnings.length})
+
    + {warnings.map((warning, i) => ( +
  • + {warning.path} + {warning.message} +
  • + ))} +
+
+ )} +
Schema

diff --git a/frontend/src/components/Layout/navItems.ts b/frontend/src/components/Layout/navItems.ts index bfeb9d1..57c725d 100644 --- a/frontend/src/components/Layout/navItems.ts +++ b/frontend/src/components/Layout/navItems.ts @@ -9,4 +9,5 @@ export const NAV_ITEMS: NavItem[] = [ { id: 'json-editor', label: 'JSON Editor' }, { id: 'preview', label: 'Preview' }, { id: 'inspector', label: 'Actions & Bindings' }, + { id: 'history', label: 'Execution History' }, ]; diff --git a/frontend/src/components/Preview/Preview.tsx b/frontend/src/components/Preview/Preview.tsx index d3e6da3..456ee49 100644 --- a/frontend/src/components/Preview/Preview.tsx +++ b/frontend/src/components/Preview/Preview.tsx @@ -3,6 +3,7 @@ import { useProject } from '../../context/ProjectContext'; import { usePreviewRuntime } from './usePreviewRuntime'; import PreviewComponent from './PreviewComponent'; import styles from './Preview.module.css'; +import ValidationSummary from '../ValidationSummary'; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -110,6 +111,7 @@ function Preview(): React.ReactElement { Read-only render of the current project. Switch to Visual Editor or JSON Editor to make changes.

+ {/* ── Page tabs (shown only when more than one page exists) ─── */} {runtime.pageLoadState.status !== 'idle' && ( diff --git a/frontend/src/components/Preview/buttonEvent.test.tsx b/frontend/src/components/Preview/buttonEvent.test.tsx index 6ad0611..4607d79 100644 --- a/frontend/src/components/Preview/buttonEvent.test.tsx +++ b/frontend/src/components/Preview/buttonEvent.test.tsx @@ -56,7 +56,7 @@ describe('Preview Button event dispatch', () => { await Promise.resolve(); await Promise.resolve(); }); - expect(executeAction).toHaveBeenCalledWith(expect.objectContaining({ id: 'action_a' })); + expect(executeAction).toHaveBeenCalledWith(expect.objectContaining({ id: 'action_a' }), { id: 'project', name: 'Project' }); expect(runtime.componentState.viewer).toMatchObject({ value: { ran: true }, loading: false, error: undefined }); }); }); diff --git a/frontend/src/components/Preview/pageLoad.test.tsx b/frontend/src/components/Preview/pageLoad.test.tsx index 6a73a2a..e3146a0 100644 --- a/frontend/src/components/Preview/pageLoad.test.tsx +++ b/frontend/src/components/Preview/pageLoad.test.tsx @@ -67,7 +67,7 @@ describe('Preview page onLoad initialization', () => { }); expect(executeAction).toHaveBeenCalledTimes(1); - expect(executeAction).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.com/Not run' })); + expect(executeAction).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.com/Not run' }), { id: 'project', name: 'Project' }); expect(runtime.componentState.viewer).toMatchObject({ value: { status: 'Loaded' }, loading: false }); expect(runtime.variableState.loadStatus).toBe('Loaded'); expect(runtime.pageLoadState).toMatchObject({ status: 'success', actionId: 'action_load' }); diff --git a/frontend/src/components/Preview/templateUtils.ts b/frontend/src/components/Preview/templateUtils.ts index bf7e175..4af81c0 100644 --- a/frontend/src/components/Preview/templateUtils.ts +++ b/frontend/src/components/Preview/templateUtils.ts @@ -389,9 +389,9 @@ function interpolateRecord( * Throws if any referenced component or undeclared variable is found. * The original action is never mutated. * - * pathParameters are not rendered — they are forwarded verbatim to the backend - * proxy which applies its own simpler substitution. Variable interpolation in - * pathParameters is explicitly excluded in Step 18.1. + * Path-parameter values use the same component and variable interpolation as + * other request fields. The backend then substitutes the rendered values into + * bare URL placeholders such as {{itemId}}. */ export function renderAction( action: RestAction, @@ -409,6 +409,9 @@ export function renderAction( queryParameters: action.queryParameters ? interpolateRecord(action.queryParameters, componentsByName, componentState, variableState, declaredVariableNames) : action.queryParameters, + pathParameters: action.pathParameters + ? interpolateRecord(action.pathParameters, componentsByName, componentState, variableState, declaredVariableNames) + : action.pathParameters, bodyTemplate: action.bodyTemplate ? interpolateString(action.bodyTemplate, componentsByName, componentState, variableState, declaredVariableNames) : action.bodyTemplate, diff --git a/frontend/src/components/Preview/usePreviewRuntime.ts b/frontend/src/components/Preview/usePreviewRuntime.ts index ceb46cc..c49eeb5 100644 --- a/frontend/src/components/Preview/usePreviewRuntime.ts +++ b/frontend/src/components/Preview/usePreviewRuntime.ts @@ -536,7 +536,7 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime { return; } - executeAction(renderedAction) + executeAction(renderedAction, {id:doc.project.id,name:doc.project.name}) .then((proxyResponse) => { const updatedActionState: ActionRuntimeStateMap = { [actionId]: { response: proxyResponse, loading: false, error: undefined }, @@ -705,7 +705,7 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime { } // ── 7. Execute proxy request ────────────────────────────────────── - executeAction(renderedAction) + executeAction(renderedAction, {id:doc.project.id,name:doc.project.name}) .then((proxyResponse) => { // Store full response envelope in action state const newActionEntry: ActionRuntimeState = { @@ -886,7 +886,7 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime { continue; } - executeAction(renderedAction) + executeAction(renderedAction, {id:doc.project.id,name:doc.project.name}) .then((proxyResponse) => { const newActionEntry: ActionRuntimeState = { response: proxyResponse, diff --git a/frontend/src/components/Preview/variables.test.ts b/frontend/src/components/Preview/variables.test.ts index a877e55..aedbdd4 100644 --- a/frontend/src/components/Preview/variables.test.ts +++ b/frontend/src/components/Preview/variables.test.ts @@ -305,15 +305,14 @@ describe('renderAction — variable interpolation', () => { expect(rendered.bodyTemplate).toBe('{"env":"prod"}'); }); - test('pathParameters are not touched by variable interpolation', () => { + test('pathParameters use variable interpolation before backend substitution', () => { const action: RestAction = { ...baseAction, url: 'https://api.example.com/items/{{itemId}}', pathParameters: { itemId: '{{variables.envId}}' }, }; const rendered = renderAction(action, noComponents, noComponentState, varState, declared); - // pathParameters are forwarded verbatim — the placeholder is NOT replaced in this field - expect(rendered.pathParameters?.itemId).toBe('{{variables.envId}}'); + expect(rendered.pathParameters?.itemId).toBe('prod'); }); test('undeclared variable in url throws', () => { @@ -1272,9 +1271,9 @@ describe('malformed variable-template detection', () => { expect(result).toBe('https://api.example.com/resource/{{customerId}}'); }); - // ── Path-parameter variable interpolation remains excluded ────────────────── + // ── Path-parameter variable interpolation ────────────────────────────────── - test('P1: renderAction does not interpolate valid variable expression in pathParameters', () => { + test('P1: renderAction interpolates valid variable expression in pathParameters', () => { const action = makeAction({ pathParameters: { id: '{{variables.selectedId}}' }, }); @@ -1285,8 +1284,7 @@ describe('malformed variable-template detection', () => { { selectedId: 'abc' }, new Set(['selectedId']), ); - // pathParameters are forwarded verbatim — no interpolation - expect(rendered.pathParameters?.id).toBe('{{variables.selectedId}}'); + expect(rendered.pathParameters?.id).toBe('abc'); }); }); diff --git a/frontend/src/components/ProjectToolbar/ProjectToolbar.tsx b/frontend/src/components/ProjectToolbar/ProjectToolbar.tsx index c04536a..bd23f6e 100644 --- a/frontend/src/components/ProjectToolbar/ProjectToolbar.tsx +++ b/frontend/src/components/ProjectToolbar/ProjectToolbar.tsx @@ -34,10 +34,16 @@ function ProjectToolbar(): React.ReactElement { }; const handleLoad = async (id: number) => { + if (isDirty && !window.confirm('Load another project and discard the current unsaved changes?')) return; setShowLoadPicker(false); await loadProject(id); }; + const handleNew = () => { + if (isDirty && !window.confirm('Create a new project and discard the current unsaved changes?')) return; + newProject(); + }; + const handleNameBlur = () => { setEditingName(false); if (nameInput.trim() && nameInput.trim() !== projectName) { @@ -87,7 +93,7 @@ function ProjectToolbar(): React.ReactElement { {/* New */}