Complete proxy observability and project validation

This commit is contained in:
Victor Wiebe 2026-08-04 13:53:57 -04:00
parent 26d0ef773a
commit 7dfb1217cc
61 changed files with 1171 additions and 353 deletions

View File

@ -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 1B1D. 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 1B1D. 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 15 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.

View File

@ -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.

View File

@ -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

View File

@ -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 1B1D 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 1B1D is pending; Test 2 has not started.
Tests 1B1D 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 15. 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

View File

@ -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": {

View File

@ -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;

View File

@ -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<string, unknown>): 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(); }

View File

@ -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');

View File

@ -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({
export function defaultProjectDocument(name: string): Record<string, unknown> {
return {
schemaVersion: '0.1.0',
project: {
name: '',
pages: [],
actions: [],
variables: {},
settings: {},
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);

View File

@ -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);
});

View File

@ -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 } : {}) };
}

View File

@ -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'));
});

View File

@ -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<string, string>): Record<string, string> {
const result: Record<string, string> = {};
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<string> {
const result = new Set<string>();
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<ValidatedDestination> {
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]'; }
}

View File

@ -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<string,string>[]=[];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'));});

View File

@ -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<string,string>; body: Buffer; finalUrl: string; redirects: number };
const redirectStatuses = new Set([301,302,303,307,308]);
export type TransportOnce = (destination: ValidatedDestination, method: string, headers: Record<string,string>, body: string | undefined, signal: AbortSignal) => Promise<TransportResult>;
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<string,string> = {};
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<string,string>, body?: string, dependencies: {validate?:typeof validateDestination;once?:TransportOnce;timeoutMs?:number}={}): Promise<TransportResult> {
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); }
}

View File

@ -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');
});

View File

@ -0,0 +1,10 @@
import { MAX_REDIRECTS, ProxyPolicyError } from './proxyPolicy';
export type RedirectState = { url: string; method: string; headers: Record<string,string>; 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};
}

View File

@ -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');});

View File

@ -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;
}

View File

@ -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'));
});

View File

@ -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<Ajv2020['compile']> | null = null;
const SCHEMA_PATH = path.resolve(__dirname, '../../../shared/schemas/conductor-project.schema.json');
let compiled: ReturnType<Ajv2020['compile']> | null = null;
function getValidator(): ReturnType<Ajv2020['compile']> {
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<string, any>;
// ── 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<string> {
const seen = new Set<string>();
const found = new Set<string>();
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<string, JsonObject[]>();
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<string>) => {
(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<string, Set<string>> = {
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.<actionId>.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.<name>.');
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',
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 };
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 };
}

View File

@ -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;

View File

@ -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<string, unknown>;
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) {

View File

@ -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<ProxyResponse> {
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<string, string> = {};
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<void> => {
// ── 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);

View File

@ -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;

View File

@ -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/...').

31
docs/PROXY_SECURITY.md Normal file
View File

@ -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.

50
docs/VALIDATION.md Normal file
View File

@ -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.<name>.value}}` and `{{variables.<name>}}` 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.

View File

@ -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"
}
],

View File

@ -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"
}
],

View File

@ -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"
}
],

View File

@ -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"
}
],

View File

@ -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"
}
],

View File

@ -79,8 +79,7 @@
},
"pathParameters": {},
"bodyTemplate": "",
"authenticationType": "anonymous",
"responseMapping": []
"authenticationType": "anonymous"
}
],

View File

@ -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"
}
],

View File

@ -1,5 +1,5 @@
{
"schemaVersion": "1.0.0",
"schemaVersion": "0.1.0",
"project": {
"id": "table-basic-example",
"name": "Table Basic Example",

View File

@ -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 <JsonEditor />;
if (activeItem === 'preview') return <Preview />;
if (activeItem === 'inspector') return <ActionInspector />;
if (activeItem === 'history') return <ExecutionHistory />;
return null;
}

View File

@ -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<ExecutionRecord[]> {
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<void> { const response=await fetch('/api/executions',{method:'DELETE'}); if(!response.ok) throw new Error('Could not clear execution history.'); }

View File

@ -12,8 +12,9 @@ async function handleResponse<T>(res: Response): Promise<T> {
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;

View File

@ -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<ProxyResponse> {
export async function executeAction(action: RestAction, project?: {id:string;name:string}): Promise<ProxyResponse> {
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) {

View File

@ -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: [] });
});

View File

@ -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<ValidationResult> {
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<ValidationResult>;
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;
}

View File

@ -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 (
<div className={styles.page}>
<ValidationSummary />
{/* ── Page header ── */}
<div className={styles.pageHeader}>
<div className={styles.pageTitle}>Actions &amp; Bindings</div>

View File

@ -52,10 +52,11 @@ export default function ResponseBindingEditor({ binding, actions, components, va
</select>
{isLegacyOnClick && <span className={styles.formHint}>This existing legacy trigger remains compatible. Choose On success to migrate it; new bindings cannot select On click.</span>}
</label>
<label className={styles.formField}>
<span className={styles.formLabel}>Transform (optional)</span>
<input className={styles.formInput} aria-label="Binding transform" value={binding.transform ?? ''} onChange={(event) => onChange({ ...binding, transform: event.target.value || undefined })} />
</label>
{binding.transform !== undefined && (
<div className={styles.formHint} role="note">
This existing transform is retained for compatibility but is not executed. Remove it in the JSON Editor to clear the warning.
</div>
)}
<button type="button" className={styles.primaryButton} onClick={onDone}>Done</button>
</div>
);

View File

@ -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 <div className={styles.section}>
<div className={styles.sectionHeader}><div><div className={styles.sectionTitle}>Secrets</div><div className={styles.sectionDescription}>Encrypted server-side credentials. Stored values are never displayed after submission.</div></div><span className={styles.sectionCount}>{secrets.length}</span></div>

View File

@ -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}

View File

@ -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<ExecutionRecord[]>([]); 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<void>{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 <section>
<div className={styles.heading}><div><h2>Execution History</h2><p>Recent sanitized proxy executions. History persists across backend restarts.</p></div><button onClick={()=>void clear()}>Clear history</button></div>
<div className={styles.filters}><label>Action ID<input value={action} onChange={e=>setAction(e.target.value)} /></label><label>Outcome<select value={outcome} onChange={e=>setOutcome(e.target.value)}><option value="">All</option><option value="success">Success</option><option value="upstream_error">Upstream error</option><option value="policy_rejected">Policy rejected</option><option value="timeout">Timeout</option><option value="size_limited">Size limited</option><option value="network_error">Network error</option></select></label><label>Status<input inputMode="numeric" value={status} onChange={e=>setStatus(e.target.value.replace(/\D/g,''))}/></label><button onClick={()=>void load()}>Refresh</button></div>
{error&&<p role="alert" className={styles.error}>{error}</p>}
{!error&&records.length===0&&<p>No executions match these filters.</p>}
<div className={styles.list}>{records.map(record=><article key={record.id} className={styles.card}>
<div className={styles.summary}><strong>{record.actionName||record.actionId||'Unnamed action'}</strong><span className={styles.outcome}>{record.outcome.replaceAll('_',' ')}</span><span>{record.status??'—'}</span><span>{record.durationMs} ms</span></div>
<div className={styles.meta}><time>{new Date(record.createdAt).toLocaleString()}</time><code>{record.method} {record.sanitizedUrl}</code></div>
{record.errorMessage&&<p className={styles.error}>{record.errorCode}: {record.errorMessage}</p>}
{record.preview&&<details><summary>Response preview ({record.responseBytes??0} bytes{record.previewTruncated?' — truncated':''})</summary><pre>{record.preview}</pre></details>}
</article>)}</div>
</section>;
}

View File

@ -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<ValidationResult> {
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<ValidationResult>;
}
// 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<ValidationResult>;
}
// 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<string | null>(null);
const [schemaErrors, setSchemaErrors] = useState<ValidationError[]>([]);
const [schemaErrors, setSchemaErrors] = useState<ValidationIssue[]>([]);
const [warnings, setWarnings] = useState<ValidationIssue[]>([]);
const [isValidating, setIsValidating] = useState(false);
const [lastApplied, setLastApplied] = useState<string | null>(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 {
<div className={styles.editor}>
{/* ── Project toolbar (shared with Visual Editor) ───────────── */}
<ProjectToolbar />
<ValidationSummary />
{/* ── Editor header ─────────────────────────────────────────── */}
<div className={styles.header}>
@ -222,6 +197,20 @@ function JsonEditor(): React.ReactElement {
</div>
)}
{warnings.length > 0 && (
<div className={styles.errorBlock}>
<div className={styles.errorTitle}>Compatibility warnings ({warnings.length})</div>
<ul className={styles.errorList}>
{warnings.map((warning, i) => (
<li key={i} className={styles.errorItem}>
<span className={styles.errorPath}>{warning.path}</span>
<span className={styles.errorDetail}>{warning.message}</span>
</li>
))}
</ul>
</div>
)}
<div className={styles.sidebarSection}>
<div className={styles.sidebarHeader}>Schema</div>
<p className={styles.schemaNote}>

View File

@ -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' },
];

View File

@ -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.
</span>
</div>
<ValidationSummary />
{/* ── Page tabs (shown only when more than one page exists) ─── */}
{runtime.pageLoadState.status !== 'idle' && (

View File

@ -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 });
});
});

View File

@ -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' });

View File

@ -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,

View File

@ -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,

View File

@ -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');
});
});

View File

@ -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 */}
<button
className={styles.btn}
onClick={() => newProject()}
onClick={handleNew}
disabled={isLoading || isSaving}
title="Create a new project (current unsaved changes will be lost)"
>

View File

@ -0,0 +1,40 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ValidationSummary from './ValidationSummary';
import { useProject } from '../context/ProjectContext';
jest.mock('../context/ProjectContext', () => ({ useProject: jest.fn() }));
const mockedUseProject = useProject as jest.Mock;
test('shows a shared blocking issue with its path in every consuming view', () => {
mockedUseProject.mockReturnValue({
validationPending: false,
validationUnavailable: null,
validation: {
valid: false,
errors: [{ code: 'DUPLICATE_OPTION_VALUE', path: '/project/pages/0/components/2/properties/options', message: 'Option value "dev" is duplicated.', severity: 'error' }],
warnings: [],
},
});
const markup = renderToStaticMarkup(<ValidationSummary />);
expect(markup).toContain('role="alert"');
expect(markup).toContain('Project validation failed');
expect(markup).toContain('/project/pages/0/components/2/properties/options');
expect(markup).toContain('Option value &quot;dev&quot; is duplicated.');
});
test('shows compatibility warnings without treating them as blocking errors', () => {
mockedUseProject.mockReturnValue({
validationPending: false,
validationUnavailable: null,
validation: {
valid: true,
errors: [],
warnings: [{ code: 'BINDING_TRANSFORM_DEFERRED', path: '/project/bindings/0/transform', message: 'Binding transforms are modeled but are not executed in v0.1.0.', severity: 'warning' }],
},
});
const markup = renderToStaticMarkup(<ValidationSummary />);
expect(markup).toContain('role="status"');
expect(markup).toContain('Compatibility warnings');
expect(markup).toContain('/project/bindings/0/transform');
});

View File

@ -0,0 +1,23 @@
import React from 'react';
import { useProject } from '../context/ProjectContext';
export default function ValidationSummary(): React.ReactElement | null {
const { validation, validationPending, validationUnavailable } = useProject();
if (validationPending && validation === null) {
return <div role="status" style={{ padding: '8px 12px', background: '#f6f8fa', color: '#57606a' }}>Validating project</div>;
}
if (validationUnavailable) {
return <div role="alert" style={{ padding: '10px 12px', background: '#fff8c5', color: '#633c01' }}>Validation unavailable: {validationUnavailable}. Current edits are preserved; saving is blocked until validation succeeds.</div>;
}
if (!validation || (validation.valid && validation.warnings.length === 0)) return null;
const issues = validation.valid ? validation.warnings : validation.errors;
return (
<div role={validation.valid ? 'status' : 'alert'} style={{ padding: '10px 12px', background: validation.valid ? '#fff8c5' : '#ffebe9', color: validation.valid ? '#633c01' : '#82071e' }}>
<strong>{validation.valid ? 'Compatibility warnings' : 'Project validation failed'}</strong>
<ul style={{ margin: '6px 0 0', paddingLeft: 20 }}>
{issues.slice(0, 5).map((issue, index) => <li key={`${issue.path}-${issue.code}-${index}`}><code>{issue.path}</code>: {issue.message}</li>)}
</ul>
{issues.length > 5 && <span>{issues.length - 5} more issue(s). Open JSON Editor for the complete list.</span>}
</div>
);
}

View File

@ -9,6 +9,7 @@ import ButtonEventEditor from './ButtonEventEditor';
import PageEventEditor from './PageEventEditor';
import ComponentDeleteDialog from './ComponentDeleteDialog';
import { findComponentReferences, type PendingComponentDeletion } from './componentDeletionUtils';
import ValidationSummary from '../ValidationSummary';
// ── Dropdown options row editor ───────────────────────────────────────────────
@ -491,6 +492,7 @@ function VisualEditor(): React.ReactElement {
<div className={styles.editor}>
{/* ── Project toolbar (New / Save / Load) ───────────────────── */}
<ProjectToolbar />
<ValidationSummary />
{/* ── Editor toolbar (page info) ────────────────────────────── */}
<div className={styles.editorBar}>

View File

@ -4,11 +4,13 @@ import React, {
useContext,
useMemo,
useState,
useEffect,
} from 'react';
import type { ProjectDocument, ApiProjectRow, Page } from '../types/project';
import type { CanvasActions } from '../store/useProjectStore';
import { useCanvasActions } from '../store/useProjectStore';
import * as api from '../api/projectsApi';
import { validateProject, type ValidationResult } from '../api/validationApi';
// ── Default document used when creating a new project ────────────────────────
@ -63,6 +65,9 @@ export type ProjectContextValue = {
isDirty: boolean;
isLoading: boolean;
isSaving: boolean;
validation: ValidationResult | null;
validationPending: boolean;
validationUnavailable: string | null;
// Project list (for the load picker)
projectList: ApiProjectRow[];
@ -117,6 +122,26 @@ export function ProjectProvider({
// ── Persistence state ─────────────────────────────────────────────────────
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [validation, setValidation] = useState<ValidationResult | null>(null);
const [validationPending, setValidationPending] = useState(false);
const [validationUnavailable, setValidationUnavailable] = useState<string | null>(null);
useEffect(() => {
let active = true;
setValidationPending(true);
const timer = setTimeout(() => {
void validateProject(doc).then((result) => {
if (!active) return;
setValidation(result);
setValidationUnavailable(null);
}).catch((error) => {
if (!active) return;
setValidation(null);
setValidationUnavailable(error instanceof Error ? error.message : String(error));
}).finally(() => { if (active) setValidationPending(false); });
}, 150);
return () => { active = false; clearTimeout(timer); };
}, [doc]);
// ── Project list ──────────────────────────────────────────────────────────
const [projectList, setProjectList] = useState<ApiProjectRow[]>([]);
@ -168,6 +193,14 @@ export function ProjectProvider({
project: { ...doc.project, name: projectName },
};
const checked = await validateProject(docToSave);
setValidation(checked);
setValidationUnavailable(null);
if (!checked.valid) {
const summary = checked.errors.slice(0, 3).map((issue) => `${issue.path}: ${issue.message}`).join('; ');
throw new Error(`Validation failed. ${summary}`);
}
let row: ApiProjectRow;
if (projectRowId === null) {
// First save — create a new record
@ -242,6 +275,9 @@ export function ProjectProvider({
isDirty,
isLoading,
isSaving,
validation,
validationPending,
validationUnavailable,
projectList,
newProject,
saveProject,
@ -262,6 +298,9 @@ export function ProjectProvider({
isDirty,
isLoading,
isSaving,
validation,
validationPending,
validationUnavailable,
projectList,
newProject,
saveProject,

View File

@ -285,6 +285,13 @@ export type Page = {
events?: ComponentEvent[];
};
export type ProjectSettings = {
theme?: 'light' | 'dark' | 'system';
defaultPageId?: string;
canvasWidth?: number;
canvasHeight?: number;
};
export type Project = {
id: string;
name: string;
@ -298,7 +305,7 @@ export type Project = {
*/
bindings: Binding[];
variables: Record<string, Variable>;
settings: Record<string, unknown>;
settings: ProjectSettings;
};
export type ProjectDocument = {