Add authentication RBAC and app publishing
This commit is contained in:
parent
e601b8fc09
commit
102e0dc46c
1
CODEX.md
1
CODEX.md
@ -330,3 +330,4 @@ Date: 2026-07-20
|
|||||||
- The user reported all Slice 6a manual UX tests pass and that the changes look good. Disposable project `#22` was deleted; backend health, frontend HTTP 200, empty secrets, and empty history pass.
|
- The user reported all Slice 6a manual UX tests pass and that the changes look good. Disposable project `#22` was deleted; backend health, frontend HTTP 200, empty secrets, and empty history pass.
|
||||||
- The user granted explicit final Slice 6a sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6a sign-off.” Slice 6a is complete.
|
- The user granted explicit final Slice 6a sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6a sign-off.” Slice 6a is complete.
|
||||||
- Slice 7 governance reconciliation completed on 2026-08-07. README, approved scope, roadmap, requirements, and architecture now consistently exclude AI and advanced capabilities from v0.1.0, reflect implemented canonical/proxy/secret/auth/history/validation behavior, and contain no material open release questions. `docs/MVP_TRACEABILITY.md` maps R1–R14 and all six accepted workflows to slices/evidence. The new `npm run test:governance` regression check passes. Slice 7 required no new manual gate because its product-owner scope decision was already approved.
|
- Slice 7 governance reconciliation completed on 2026-08-07. README, approved scope, roadmap, requirements, and architecture now consistently exclude AI and advanced capabilities from v0.1.0, reflect implemented canonical/proxy/secret/auth/history/validation behavior, and contain no material open release questions. `docs/MVP_TRACEABILITY.md` maps R1–R14 and all six accepted workflows to slices/evidence. The new `npm run test:governance` regression check passes. Slice 7 required no new manual gate because its product-owner scope decision was already approved.
|
||||||
|
- The product owner added Slice 7a local authentication/RBAC and immutable standalone publishing to the v0.1 boundary before Slice 8. Agreed defaults: global admin/user roles, admin-only authoring/user/publishing management, public versus authenticated snapshots, admin access to all apps, all authenticated users access to restricted apps, public anonymous-action-only enforcement, and server-owned published execution. Slice 7a establishes provider-neutral auth architecture; post-v0.1 Slice 9 adds OIDC/SSO. Traceability and governance checks now cover R1–R16.
|
||||||
|
|||||||
17
MVP_SCOPE.md
17
MVP_SCOPE.md
@ -68,10 +68,23 @@ These are end-to-end acceptance scenarios. They demonstrate that Conductor can b
|
|||||||
- [x] Confirm canonical project state is not corrupted.
|
- [x] Confirm canonical project state is not corrupted.
|
||||||
- [x] Confirm unsaved work is not lost.
|
- [x] Confirm unsaved work is not lost.
|
||||||
|
|
||||||
|
## Authentication, Authorization, and Publishing Addendum
|
||||||
|
|
||||||
|
Approved on 2026-08-07 for Slice 7a:
|
||||||
|
|
||||||
|
- Local username/password authentication with global `admin` and `user` roles is required before v0.1.0 packaging.
|
||||||
|
- Admins alone may author projects, manage users/secrets, and publish applications.
|
||||||
|
- Publishing creates an immutable validated snapshot under a standalone application URL without editor controls.
|
||||||
|
- Published visibility is either public or restricted to all authenticated users; admins can access every active published application.
|
||||||
|
- Public snapshots may contain only anonymous REST actions. Authenticated snapshots may use server-resolved credential-backed actions.
|
||||||
|
- Published action execution must resolve the server-owned snapshot and must not accept browser-substituted URLs, action definitions, authentication modes, or secret references.
|
||||||
|
- Authentication and authorization architecture must remain provider-neutral so post-v0.1.0 OIDC/SSO can reuse the same users, roles, sessions, capabilities, and publication rules.
|
||||||
|
- Per-application assignments, custom roles, groups, and OIDC/SSO are not part of Slice 7a.
|
||||||
|
|
||||||
## Release Boundary
|
## Release Boundary
|
||||||
|
|
||||||
The MVP is complete only when all required component, configuration, authentication, security, validation, persistence, and deployment tasks in `ROADMAP.md` are complete and all six workflows above pass the Slice 6 release-validation process.
|
The MVP is complete only when all required component, configuration, authentication, security, validation, persistence, local-user/RBAC, publishing, first-run administration, and deployment tasks in `ROADMAP.md` are complete; all six workflows above pass the Slice 6 release-validation process; and Slice 7a and Slice 7b acceptance pass.
|
||||||
|
|
||||||
Post-MVP scope includes AI assistance, OAuth 2.0, IBM Cloud IAM, mTLS, advanced orchestration, and the future capabilities listed in `ROADMAP.md`.
|
Post-MVP scope includes AI assistance, OIDC/SSO beyond the local authentication architecture, OAuth 2.0 for REST actions, IBM Cloud IAM, mTLS, advanced orchestration, and the future capabilities listed in `ROADMAP.md`.
|
||||||
|
|
||||||
Requirement-to-slice and validation traceability is maintained in `docs/MVP_TRACEABILITY.md`.
|
Requirement-to-slice and validation traceability is maintained in `docs/MVP_TRACEABILITY.md`.
|
||||||
|
|||||||
24
ROADMAP.md
24
ROADMAP.md
@ -25,7 +25,10 @@ The slice files remain the detailed implementation plans. When an older list con
|
|||||||
| [Slice 6](SLICE6.md) | Testing and release validation | Complete | None |
|
| [Slice 6](SLICE6.md) | Testing and release validation | Complete | None |
|
||||||
| [Slice 6a](SLICE6a.md) | Editor information density and progressive disclosure | Complete | None |
|
| [Slice 6a](SLICE6a.md) | Editor information density and progressive disclosure | Complete | None |
|
||||||
| [Slice 7](SLICE7.md) | MVP scope decision and requirement governance | Complete | None |
|
| [Slice 7](SLICE7.md) | MVP scope decision and requirement governance | Complete | None |
|
||||||
|
| [Slice 7a](SLICE7a.md) | Local authentication, RBAC, and application publishing | Complete | Implementation, security validation, manual acceptance, cleanup, and explicit sign-off passed |
|
||||||
|
| [Slice 7b](SLICE7b.md) | Browser-based first-run administrator setup | Planned | Fresh-install setup, takeover prevention, recovery validation, and explicit sign-off |
|
||||||
| [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain |
|
| [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain |
|
||||||
|
| [Slice 9](SLICE9.md) | OIDC and enterprise SSO | Post-MVP | Begins after Slice 7a; provider and provisioning decisions remain |
|
||||||
|
|
||||||
## 1. MVP GUI Components
|
## 1. MVP GUI Components
|
||||||
|
|
||||||
@ -241,8 +244,29 @@ Controlled orchestration is not required in the v0.1.0 demonstration. Execution
|
|||||||
- [x] Remove or resolve every open question that could materially change the release boundary.
|
- [x] Remove or resolve every open question that could materially change the release boundary.
|
||||||
- [x] Produce a definitive Slice 6 release checklist and record product-owner approval.
|
- [x] Produce a definitive Slice 6 release checklist and record product-owner approval.
|
||||||
|
|
||||||
|
### Slice 7a — Local identity, RBAC, and publishing
|
||||||
|
|
||||||
|
- [x] Implement secure local accounts and sessions with global `admin` and `user` roles.
|
||||||
|
- [x] Restrict authoring, project, secret, history-management, user-management, and publishing capabilities to admins.
|
||||||
|
- [x] Implement immutable validated publication snapshots with public/authenticated visibility, republish versioning, and unpublish behavior.
|
||||||
|
- [x] Render published applications in a standalone runtime shell without editor controls.
|
||||||
|
- [x] Resolve published actions exclusively from server-owned snapshots and reject browser-substituted execution configuration.
|
||||||
|
- [x] Block public publication of credential-backed actions; preserve secret-safe execution for authenticated apps.
|
||||||
|
- [x] Complete automated security/role/publication coverage, proportional manual acceptance, cleanup, and explicit sign-off.
|
||||||
|
- [x] Keep principal/session/capability architecture independent of the authentication provider for Slice 9.
|
||||||
|
|
||||||
|
### Slice 7b — First-run administrator setup
|
||||||
|
|
||||||
|
- [ ] Replace the normal Docker-command bootstrap experience with a secure browser-based first-run setup screen.
|
||||||
|
- [ ] Make initial-admin creation atomic and available only while the installation contains no users.
|
||||||
|
- [ ] Create an authenticated session after successful setup without exposing passwords or session values.
|
||||||
|
- [ ] Preserve the CLI bootstrap only as a documented emergency/recovery path.
|
||||||
|
- [ ] Complete fresh-install, concurrent-takeover, existing-installation, restart, and manual acceptance coverage.
|
||||||
|
|
||||||
## 8. Documentation, Packaging, and Release
|
## 8. Documentation, Packaging, and Release
|
||||||
|
|
||||||
|
Slice 8 release packaging now depends on completed Slice 7a authentication/RBAC/publishing and Slice 7b first-run setup behavior and documentation. Slice 9 OIDC/SSO remains post-v0.1.0 unless the product owner explicitly changes the boundary.
|
||||||
|
|
||||||
### Slice 8 — Documentation ownership and reconciliation
|
### Slice 8 — Documentation ownership and reconciliation
|
||||||
|
|
||||||
- [x] Create and link a unified v0.1.0 roadmap grouped by function and slice.
|
- [x] Create and link a unified v0.1.0 roadmap grouped by function and slice.
|
||||||
|
|||||||
@ -89,6 +89,8 @@ The already approved Slice 7 product decisions were reconciled across `README.md
|
|||||||
|
|
||||||
`docs/MVP_TRACEABILITY.md` maps 14 release-critical requirement areas and all six workflows to owning slices and validation evidence, and explicitly classifies deferred capabilities post-MVP. `npm run test:governance` passes, checking authority statements, accepted workflow state, forbidden stale claims, Slice 7 roadmap status, R1–R14 coverage, and relative documentation links. No new product behavior or manual test gate was introduced; the release boundary already has product-owner approval.
|
`docs/MVP_TRACEABILITY.md` maps 14 release-critical requirement areas and all six workflows to owning slices and validation evidence, and explicitly classifies deferred capabilities post-MVP. `npm run test:governance` passes, checking authority statements, accepted workflow state, forbidden stale claims, Slice 7 roadmap status, R1–R14 coverage, and relative documentation links. No new product behavior or manual test gate was introduced; the release boundary already has product-owner approval.
|
||||||
|
|
||||||
|
The product owner subsequently added Slice 7a local authentication/RBAC and standalone application publishing to the pre-Slice-8 v0.1 boundary. Traceability now includes planned R15–R16. Slice 9 reserves post-v0.1 OIDC/SSO and must reuse Slice 7a's provider-neutral principal/session/capability architecture. The governance check now requires R1–R16.
|
||||||
|
|
||||||
## Slice 4 Manual Gate
|
## 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.
|
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.
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"bootstrap-admin": "npm run build && node dist/scripts/bootstrapAdmin.js",
|
||||||
"test": "npm run build && node --test dist/**/*.test.js",
|
"test": "npm run build && node --test dist/**/*.test.js",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,6 +7,11 @@ import validateRouter from './routes/validate';
|
|||||||
import proxyRouter from './routes/proxy';
|
import proxyRouter from './routes/proxy';
|
||||||
import secretsRouter from './routes/secrets';
|
import secretsRouter from './routes/secrets';
|
||||||
import executionsRouter from './routes/executions';
|
import executionsRouter from './routes/executions';
|
||||||
|
import authRouter from './routes/auth';
|
||||||
|
import usersRouter from './routes/users';
|
||||||
|
import publishedAppsRouter from './routes/publishedApps';
|
||||||
|
import adminPublishedAppsRouter from './routes/adminPublishedApps';
|
||||||
|
import { optionalAuth, requireAdmin, requireMutationCsrf } from './middleware/auth';
|
||||||
|
|
||||||
const app: Application = express();
|
const app: Application = express();
|
||||||
|
|
||||||
@ -18,11 +23,15 @@ app.use(morgan('dev'));
|
|||||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use('/api/health', healthRouter);
|
app.use('/api/health', healthRouter);
|
||||||
app.use('/api/projects/validate', validateRouter);
|
app.use('/api/auth', authRouter);
|
||||||
app.use('/api/projects', projectsRouter);
|
app.use('/api/published-apps', optionalAuth, publishedAppsRouter);
|
||||||
app.use('/api/secrets', secretsRouter);
|
app.use('/api/admin/users', requireAdmin, requireMutationCsrf, usersRouter);
|
||||||
app.use('/api/executions', executionsRouter);
|
app.use('/api/admin/published-apps', requireAdmin, requireMutationCsrf, adminPublishedAppsRouter);
|
||||||
app.use('/api/proxy/execute', proxyRouter);
|
app.use('/api/projects/validate', requireAdmin, requireMutationCsrf, validateRouter);
|
||||||
|
app.use('/api/projects', requireAdmin, requireMutationCsrf, projectsRouter);
|
||||||
|
app.use('/api/secrets', requireAdmin, requireMutationCsrf, secretsRouter);
|
||||||
|
app.use('/api/executions', requireAdmin, requireMutationCsrf, executionsRouter);
|
||||||
|
app.use('/api/proxy/execute', requireAdmin, requireMutationCsrf, proxyRouter);
|
||||||
|
|
||||||
// ── Catch-all 404 ─────────────────────────────────────────────────────────────
|
// ── Catch-all 404 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import db from './database';
|
import db from './database';
|
||||||
|
|
||||||
export type ExecutionOutcome = 'success'|'upstream_error'|'policy_rejected'|'timeout'|'size_limited'|'network_error';
|
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 type ExecutionInput = { projectId?: string; projectName?: string; publishedAppId?:string; publishedVersion?:number; userId?: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 {
|
export function recordExecution(value: ExecutionInput): void {
|
||||||
const transaction = db.transaction(() => {
|
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(`INSERT INTO executions (project_id,project_name,published_app_id,published_version,user_id,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.publishedAppId??null,value.publishedVersion??null,value.userId??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();
|
db.prepare('DELETE FROM executions WHERE id NOT IN (SELECT id FROM executions ORDER BY id DESC LIMIT 1000)').run();
|
||||||
}); transaction();
|
}); transaction();
|
||||||
}
|
}
|
||||||
@ -14,6 +14,6 @@ export function listExecutions(filters: Record<string, unknown>): unknown[] {
|
|||||||
const clauses: string[]=[]; const params: 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]); }
|
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;
|
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);
|
return db.prepare(`SELECT id,created_at AS createdAt,project_id AS projectId,project_name AS projectName,published_app_id AS publishedAppId,published_version AS publishedVersion,user_id AS userId,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(); }
|
export function clearExecutions(): void { db.prepare('DELETE FROM executions').run(); }
|
||||||
|
|||||||
@ -34,10 +34,60 @@ export function initDatabase(): void {
|
|||||||
duration_ms INTEGER NOT NULL, outcome TEXT NOT NULL, error_code TEXT,
|
duration_ms INTEGER NOT NULL, outcome TEXT NOT NULL, error_code TEXT,
|
||||||
error_message TEXT, preview TEXT, response_bytes INTEGER,
|
error_message TEXT, preview TEXT, response_bytes INTEGER,
|
||||||
preview_truncated INTEGER NOT NULL DEFAULT 0, content_type TEXT
|
preview_truncated INTEGER NOT NULL DEFAULT 0, content_type TEXT
|
||||||
|
,published_app_id TEXT, published_version INTEGER, user_id TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_executions_action ON executions(action_id, id DESC);
|
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);
|
CREATE INDEX IF NOT EXISTS idx_executions_outcome ON executions(outcome, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
normalized_username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
provider TEXT NOT NULL DEFAULT 'local',
|
||||||
|
provider_subject TEXT,
|
||||||
|
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')),
|
||||||
|
last_login_at TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_subject
|
||||||
|
ON users(provider, provider_subject) WHERE provider_subject IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id_hash TEXT PRIMARY KEY,
|
||||||
|
csrf_hash TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
issued_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id, expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS published_apps (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
source_project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
visibility TEXT NOT NULL CHECK (visibility IN ('public', 'authenticated')),
|
||||||
|
snapshot_json TEXT NOT NULL,
|
||||||
|
schema_version TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
published_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
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 INDEX IF NOT EXISTS idx_published_apps_source ON published_apps(source_project_id, active);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const executionColumns = new Set((db.pragma('table_info(executions)') as Array<{name:string}>).map((column) => column.name));
|
||||||
|
if (!executionColumns.has('published_app_id')) db.exec('ALTER TABLE executions ADD COLUMN published_app_id TEXT');
|
||||||
|
if (!executionColumns.has('published_version')) db.exec('ALTER TABLE executions ADD COLUMN published_version INTEGER');
|
||||||
|
if (!executionColumns.has('user_id')) db.exec('ALTER TABLE executions ADD COLUMN user_id TEXT');
|
||||||
|
|
||||||
console.log('Database initialised');
|
console.log('Database initialised');
|
||||||
}
|
}
|
||||||
|
|||||||
16
backend/src/db/publishedApps.ts
Normal file
16
backend/src/db/publishedApps.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import db from './database';
|
||||||
|
export type PublishedVisibility='public'|'authenticated';
|
||||||
|
export type PublishedAppRow={id:string;slug:string;source_project_id:number;display_name:string;description:string;visibility:PublishedVisibility;snapshot_json:string;schema_version:string;version:number;active:number;published_by:string;created_at:string;updated_at:string};
|
||||||
|
export type PublishedAppAdmin={id:string;slug:string;sourceProjectId:number;displayName:string;description:string;visibility:PublishedVisibility;version:number;active:boolean;publishedBy:string;createdAt:string;updatedAt:string};
|
||||||
|
const admin=(r:PublishedAppRow):PublishedAppAdmin=>({id:r.id,slug:r.slug,sourceProjectId:r.source_project_id,displayName:r.display_name,description:r.description,visibility:r.visibility,version:r.version,active:r.active===1,publishedBy:r.published_by,createdAt:r.created_at,updatedAt:r.updated_at});
|
||||||
|
export function listPublishedAdmin():PublishedAppAdmin[]{return(db.prepare('SELECT * FROM published_apps ORDER BY display_name COLLATE NOCASE').all() as PublishedAppRow[]).map(admin);}
|
||||||
|
export function listActivePublishedRows():PublishedAppRow[]{return db.prepare('SELECT * FROM published_apps WHERE active=1 ORDER BY display_name COLLATE NOCASE').all() as PublishedAppRow[];}
|
||||||
|
export function getPublishedRowById(id:string):PublishedAppRow|undefined{return db.prepare('SELECT * FROM published_apps WHERE id=?').get(id) as PublishedAppRow|undefined;}
|
||||||
|
export function getActivePublishedBySlug(slug:string):PublishedAppRow|undefined{return db.prepare('SELECT * FROM published_apps WHERE slug=? AND active=1').get(slug) as PublishedAppRow|undefined;}
|
||||||
|
export function hasActivePublication(projectId:number):boolean{return !!db.prepare('SELECT 1 FROM published_apps WHERE source_project_id=? AND active=1 LIMIT 1').get(projectId);}
|
||||||
|
export function deleteInactivePublications(projectId:number):void{db.prepare('DELETE FROM published_apps WHERE source_project_id=? AND active=0').run(projectId);}
|
||||||
|
export function createPublished(input:{slug:string;sourceProjectId:number;displayName:string;description:string;visibility:PublishedVisibility;snapshotJson:string;schemaVersion:string;publishedBy:string}):PublishedAppAdmin{const id=randomUUID();db.prepare('INSERT INTO published_apps (id,slug,source_project_id,display_name,description,visibility,snapshot_json,schema_version,published_by) VALUES (?,?,?,?,?,?,?,?,?)').run(id,input.slug,input.sourceProjectId,input.displayName,input.description,input.visibility,input.snapshotJson,input.schemaVersion,input.publishedBy);return admin(getPublishedRowById(id)!);}
|
||||||
|
export function updatePublishedMetadata(id:string,input:{slug:string;displayName:string;description:string;visibility:PublishedVisibility}):PublishedAppAdmin|undefined{db.prepare("UPDATE published_apps SET slug=?,display_name=?,description=?,visibility=?,updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?").run(input.slug,input.displayName,input.description,input.visibility,id);const row=getPublishedRowById(id);return row?admin(row):undefined;}
|
||||||
|
export function republish(id:string,snapshotJson:string,schemaVersion:string,publishedBy:string):PublishedAppAdmin|undefined{db.prepare("UPDATE published_apps SET snapshot_json=?,schema_version=?,version=version+1,active=1,published_by=?,updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?").run(snapshotJson,schemaVersion,publishedBy,id);const row=getPublishedRowById(id);return row?admin(row):undefined;}
|
||||||
|
export function setPublishedActive(id:string,active:boolean):PublishedAppAdmin|undefined{db.prepare("UPDATE published_apps SET active=?,updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?").run(active?1:0,id);const row=getPublishedRowById(id);return row?admin(row):undefined;}
|
||||||
22
backend/src/db/sessions.ts
Normal file
22
backend/src/db/sessions.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { createHmac, randomBytes } from 'crypto';
|
||||||
|
import db from './database';
|
||||||
|
|
||||||
|
const SESSION_MS = 8 * 60 * 60 * 1000;
|
||||||
|
const sessionKey=process.env.CONDUCTOR_SESSION_KEY||(process.env.NODE_ENV==='production'?'':'conductor-development-session-key');
|
||||||
|
if(!sessionKey)throw new Error('CONDUCTOR_SESSION_KEY is required when NODE_ENV=production.');
|
||||||
|
const hash = (value:string) => createHmac('sha256',sessionKey).update(value).digest('base64url');
|
||||||
|
export type SessionTokens = { sessionToken:string; csrfToken:string; expiresAt:string };
|
||||||
|
export type SessionPrincipalRow = { user_id:string; username:string; role:'admin'|'user'; enabled:number; expires_at:string; revoked_at:string|null; csrf_hash:string };
|
||||||
|
|
||||||
|
export function createSession(userId:string):SessionTokens {
|
||||||
|
const sessionToken=randomBytes(32).toString('base64url'); const csrfToken=randomBytes(32).toString('base64url');
|
||||||
|
const issuedAt=new Date().toISOString(); const expiresAt=new Date(Date.now()+SESSION_MS).toISOString();
|
||||||
|
db.prepare('INSERT INTO sessions (id_hash,csrf_hash,user_id,issued_at,expires_at) VALUES (?,?,?,?,?)').run(hash(sessionToken),hash(csrfToken),userId,issuedAt,expiresAt);
|
||||||
|
return {sessionToken,csrfToken,expiresAt};
|
||||||
|
}
|
||||||
|
export function resolveSession(token:string):SessionPrincipalRow|undefined {
|
||||||
|
return db.prepare(`SELECT s.user_id,u.username,u.role,u.enabled,s.expires_at,s.revoked_at,s.csrf_hash FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.id_hash=?`).get(hash(token)) as SessionPrincipalRow|undefined;
|
||||||
|
}
|
||||||
|
export function verifyCsrf(row:SessionPrincipalRow,token:string|undefined):boolean { return !!token && row.csrf_hash===hash(token); }
|
||||||
|
export function revokeSession(token:string):void { db.prepare("UPDATE sessions SET revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id_hash=?").run(hash(token)); }
|
||||||
|
export function revokeUserSessions(userId:string):void { db.prepare("UPDATE sessions SET revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE user_id=? AND revoked_at IS NULL").run(userId); }
|
||||||
26
backend/src/db/users.ts
Normal file
26
backend/src/db/users.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import db from './database';
|
||||||
|
|
||||||
|
export type UserRole = 'admin' | 'user';
|
||||||
|
export type UserRow = { id:string; username:string; normalized_username:string; password_hash:string|null; role:UserRole; enabled:number; provider:string; provider_subject:string|null; created_at:string; updated_at:string; last_login_at:string|null };
|
||||||
|
export type UserPublic = { id:string; username:string; role:UserRole; enabled:boolean; provider:string; createdAt:string; updatedAt:string; lastLoginAt:string|null };
|
||||||
|
|
||||||
|
const normalize = (username:string) => username.trim().toLocaleLowerCase('en-US');
|
||||||
|
const publicUser = (row:UserRow):UserPublic => ({id:row.id,username:row.username,role:row.role,enabled:row.enabled===1,provider:row.provider,createdAt:row.created_at,updatedAt:row.updated_at,lastLoginAt:row.last_login_at});
|
||||||
|
|
||||||
|
export function getUserRowById(id:string):UserRow|undefined { return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow|undefined; }
|
||||||
|
export function findLocalUser(username:string):UserRow|undefined { return db.prepare('SELECT * FROM users WHERE normalized_username = ? AND provider = ?').get(normalize(username),'local') as UserRow|undefined; }
|
||||||
|
export function listUsers():UserPublic[] { return (db.prepare('SELECT * FROM users ORDER BY username COLLATE NOCASE').all() as UserRow[]).map(publicUser); }
|
||||||
|
export function getUser(id:string):UserPublic|undefined { const row=getUserRowById(id); return row?publicUser(row):undefined; }
|
||||||
|
export function countAdmins():number { return (db.prepare("SELECT count(*) AS count FROM users WHERE role='admin' AND enabled=1").get() as {count:number}).count; }
|
||||||
|
export function createLocalUser(username:string,passwordHash:string,role:UserRole):UserPublic {
|
||||||
|
const clean=username.trim(); if(!clean||clean.length>120) throw new Error('Username must contain 1 to 120 characters.');
|
||||||
|
const id=randomUUID(); db.prepare('INSERT INTO users (id,username,normalized_username,password_hash,role,provider) VALUES (?,?,?,?,?,?)').run(id,clean,normalize(clean),passwordHash,role,'local');
|
||||||
|
return getUser(id)!;
|
||||||
|
}
|
||||||
|
export function updateUser(id:string,input:{role?:UserRole;enabled?:boolean;passwordHash?:string}):UserPublic|undefined {
|
||||||
|
const row=getUserRowById(id); if(!row)return undefined;
|
||||||
|
db.prepare("UPDATE users SET role=?, enabled=?, password_hash=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?").run(input.role??row.role,input.enabled===undefined?row.enabled:(input.enabled?1:0),input.passwordHash??row.password_hash,id);
|
||||||
|
return getUser(id);
|
||||||
|
}
|
||||||
|
export function markLogin(id:string):void { db.prepare("UPDATE users SET last_login_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?").run(id); }
|
||||||
3
backend/src/lib/authProvider.ts
Normal file
3
backend/src/lib/authProvider.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import type { UserRole } from '../db/users';
|
||||||
|
export type AuthenticatedPrincipal = { userId:string; username:string; role:UserRole; provider:string };
|
||||||
|
export interface AuthenticationProvider<TInput=unknown> { authenticate(input:TInput):Promise<AuthenticatedPrincipal|null>; }
|
||||||
10
backend/src/lib/localAuthProvider.ts
Normal file
10
backend/src/lib/localAuthProvider.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { findLocalUser, markLogin } from '../db/users';
|
||||||
|
import { verifyPassword } from './passwords';
|
||||||
|
import type { AuthenticationProvider, AuthenticatedPrincipal } from './authProvider';
|
||||||
|
|
||||||
|
export class LocalAuthenticationProvider implements AuthenticationProvider<{username:string;password:string}> {
|
||||||
|
async authenticate(input:{username:string;password:string}):Promise<AuthenticatedPrincipal|null> {
|
||||||
|
const user=findLocalUser(input.username); if(!user||user.enabled!==1||!await verifyPassword(input.password,user.password_hash))return null;
|
||||||
|
markLogin(user.id); return {userId:user.id,username:user.username,role:user.role,provider:'local'};
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/src/lib/passwords.ts
Normal file
30
backend/src/lib/passwords.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'crypto';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const scrypt = promisify(scryptCallback);
|
||||||
|
const KEY_LENGTH = 64;
|
||||||
|
|
||||||
|
export function validatePassword(password: string): string | null {
|
||||||
|
if (password.length < 12) return 'Password must contain at least 12 characters.';
|
||||||
|
if (password.length > 256) return 'Password must contain at most 256 characters.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
const issue = validatePassword(password);
|
||||||
|
if (issue) throw new Error(issue);
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const derived = await scrypt(password, salt, KEY_LENGTH) as Buffer;
|
||||||
|
return `scrypt$${salt.toString('base64url')}$${derived.toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, encoded: string | null): Promise<boolean> {
|
||||||
|
if (!encoded) return false;
|
||||||
|
const [algorithm, saltText, hashText] = encoded.split('$');
|
||||||
|
if (algorithm !== 'scrypt' || !saltText || !hashText) return false;
|
||||||
|
try {
|
||||||
|
const expected = Buffer.from(hashText, 'base64url');
|
||||||
|
const derived = await scrypt(password, Buffer.from(saltText, 'base64url'), expected.length) as Buffer;
|
||||||
|
return expected.length === derived.length && timingSafeEqual(expected, derived);
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
19
backend/src/middleware/auth.ts
Normal file
19
backend/src/middleware/auth.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { resolveSession, verifyCsrf, type SessionPrincipalRow } from '../db/sessions';
|
||||||
|
import type { AuthenticatedPrincipal } from '../lib/authProvider';
|
||||||
|
|
||||||
|
declare global { namespace Express { interface Request { principal?:AuthenticatedPrincipal; sessionToken?:string; sessionRow?:SessionPrincipalRow } } }
|
||||||
|
function cookie(req:Request,name:string):string|undefined { const raw=req.headers.cookie??''; for(const part of raw.split(';')){const [key,...rest]=part.trim().split('=');if(key===name)return decodeURIComponent(rest.join('='));} return undefined; }
|
||||||
|
export function optionalAuth(req:Request,_res:Response,next:NextFunction):void {
|
||||||
|
const token=cookie(req,'conductor_session'); if(!token){next();return;} const row=resolveSession(token);
|
||||||
|
if(!row||row.revoked_at||row.enabled!==1||Date.parse(row.expires_at)<=Date.now()){next();return;}
|
||||||
|
req.sessionToken=token;req.sessionRow=row;req.principal={userId:row.user_id,username:row.username,role:row.role,provider:'local'};next();
|
||||||
|
}
|
||||||
|
export function requireAuth(req:Request,res:Response,next:NextFunction):void { optionalAuth(req,res,()=>{if(!req.principal){res.status(401).json({code:'AUTH_REQUIRED',error:'Authentication is required.'});return;}next();}); }
|
||||||
|
export function requireAdmin(req:Request,res:Response,next:NextFunction):void { requireAuth(req,res,()=>{if(req.principal?.role!=='admin'){res.status(403).json({code:'ADMIN_REQUIRED',error:'Administrator access is required.'});return;}next();}); }
|
||||||
|
export function requireCsrf(req:Request,res:Response,next:NextFunction):void { if(!req.sessionRow||!verifyCsrf(req.sessionRow,req.header('x-csrf-token'))){res.status(403).json({code:'CSRF_INVALID',error:'The security token is missing or invalid.'});return;}next(); }
|
||||||
|
export function requireMutationCsrf(req:Request,res:Response,next:NextFunction):void { if(req.method==='GET'||req.method==='HEAD'||req.method==='OPTIONS'){next();return;}requireCsrf(req,res,next); }
|
||||||
|
export const sessionCookie = (token:string,expiresAt:string) => `conductor_session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Expires=${new Date(expiresAt).toUTCString()}${process.env.NODE_ENV==='production'?'; Secure':''}`;
|
||||||
|
export const csrfCookie = (token:string,expiresAt:string) => `conductor_csrf=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Expires=${new Date(expiresAt).toUTCString()}${process.env.NODE_ENV==='production'?'; Secure':''}`;
|
||||||
|
export const clearSessionCookie = () => `conductor_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${process.env.NODE_ENV==='production'?'; Secure':''}`;
|
||||||
|
export const clearCsrfCookie = () => `conductor_csrf=; Path=/; SameSite=Lax; Max-Age=0${process.env.NODE_ENV==='production'?'; Secure':''}`;
|
||||||
13
backend/src/routes/adminPublishedApps.ts
Normal file
13
backend/src/routes/adminPublishedApps.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { createPublished, getPublishedRowById, listPublishedAdmin, republish, setPublishedActive, updatePublishedMetadata, type PublishedVisibility } from '../db/publishedApps';
|
||||||
|
import { getProjectById } from '../db/projects';
|
||||||
|
import { validateProjectDocument } from '../lib/validateProject';
|
||||||
|
const router=Router(); const validSlug=(value:string)=>/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)&&value.length<=80; const visibility=(value:unknown):value is PublishedVisibility=>value==='public'||value==='authenticated';
|
||||||
|
type SnapshotResult={json:string;schemaVersion:string}|{error:{status:number;code:string;message:string;errors?:unknown[]}};
|
||||||
|
function snapshot(projectId:number,nextVisibility:PublishedVisibility):SnapshotResult{const project=getProjectById(projectId);if(!project)return{error:{status:404,code:'PROJECT_NOT_FOUND',message:'Project not found.'}};let document:unknown;try{document=JSON.parse(project.project_json);}catch{return{error:{status:409,code:'STORED_PROJECT_INVALID',message:'Stored project is invalid.'}};}const checked=validateProjectDocument(document);if(!checked.valid)return{error:{status:422,code:'PROJECT_VALIDATION_FAILED',message:'Project document is invalid.',errors:checked.errors}};const doc=document as {schemaVersion:string;project:{actions?:Array<{authenticationType?:string}>}};if(nextVisibility==='public'&&(doc.project.actions??[]).some((action)=>action.authenticationType&&action.authenticationType!=='anonymous'))return{error:{status:409,code:'PUBLIC_CREDENTIAL_ACTION_FORBIDDEN',message:'Public applications may contain only anonymous REST actions.'}};return{json:JSON.stringify(document),schemaVersion:doc.schemaVersion};}
|
||||||
|
router.get('/',(_req,res)=>res.json(listPublishedAdmin()));
|
||||||
|
router.post('/',(req,res)=>{const projectId=Number(req.body?.sourceProjectId),slug=typeof req.body?.slug==='string'?req.body.slug.trim():'',displayName=typeof req.body?.displayName==='string'?req.body.displayName.trim():'';const nextVisibility=req.body?.visibility;if(!Number.isInteger(projectId)||!validSlug(slug)||!displayName||!visibility(nextVisibility)){res.status(400).json({code:'PUBLICATION_INPUT_INVALID',error:'Source project, safe slug, display name, and visibility are required.'});return;}const checked=snapshot(projectId,nextVisibility);if('error'in checked){res.status(checked.error.status).json({code:checked.error.code,error:checked.error.message,errors:checked.error.errors});return;}try{res.status(201).json(createPublished({slug,sourceProjectId:projectId,displayName,description:typeof req.body?.description==='string'?req.body.description:'',visibility:nextVisibility,snapshotJson:checked.json,schemaVersion:checked.schemaVersion,publishedBy:req.principal!.userId}));}catch{res.status(409).json({code:'PUBLISHED_SLUG_EXISTS',error:'That application URL slug is already in use.'});}});
|
||||||
|
router.put('/:id',(req,res)=>{const row=getPublishedRowById(req.params.id);if(!row){res.status(404).json({code:'PUBLISHED_APP_NOT_FOUND',error:'Published application not found.'});return;}const slug=typeof req.body?.slug==='string'?req.body.slug.trim():row.slug,displayName=typeof req.body?.displayName==='string'?req.body.displayName.trim():row.display_name,nextVisibility=visibility(req.body?.visibility)?req.body.visibility:row.visibility;if(!validSlug(slug)||!displayName){res.status(400).json({code:'PUBLICATION_INPUT_INVALID',error:'A safe slug and display name are required.'});return;}if(nextVisibility==='public'){const checked=snapshot(row.source_project_id,nextVisibility);if('error'in checked){res.status(checked.error.status).json({code:checked.error.code,error:checked.error.message});return;}}try{res.json(updatePublishedMetadata(row.id,{slug,displayName,description:typeof req.body?.description==='string'?req.body.description:row.description,visibility:nextVisibility}));}catch{res.status(409).json({code:'PUBLISHED_SLUG_EXISTS',error:'That application URL slug is already in use.'});}});
|
||||||
|
router.post('/:id/publish',(req,res)=>{const row=getPublishedRowById(req.params.id);if(!row){res.status(404).json({code:'PUBLISHED_APP_NOT_FOUND',error:'Published application not found.'});return;}const checked=snapshot(row.source_project_id,row.visibility);if('error'in checked){res.status(checked.error.status).json({code:checked.error.code,error:checked.error.message,errors:checked.error.errors});return;}res.json(republish(row.id,checked.json,checked.schemaVersion,req.principal!.userId));});
|
||||||
|
router.post('/:id/unpublish',(req,res)=>{const changed=setPublishedActive(req.params.id,false);if(!changed){res.status(404).json({code:'PUBLISHED_APP_NOT_FOUND',error:'Published application not found.'});return;}res.json(changed);});
|
||||||
|
export default router;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { spawn, type ChildProcess } from 'node:child_process';
|
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||||
import { createServer, type IncomingMessage } from 'node:http';
|
import { createServer, type IncomingMessage } from 'node:http';
|
||||||
import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
@ -8,6 +8,8 @@ import path from 'node:path';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
|
|
||||||
type Json = Record<string, unknown>;
|
type Json = Record<string, unknown>;
|
||||||
|
let authCookie = '';
|
||||||
|
let csrfToken = '';
|
||||||
|
|
||||||
function freePort(): Promise<number> {
|
function freePort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@ -42,7 +44,10 @@ async function waitForBackend(child: ChildProcess, baseUrl: string): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function jsonRequest(baseUrl: string, pathname: string, init?: RequestInit): Promise<{ response: Response; body: any }> {
|
async function jsonRequest(baseUrl: string, pathname: string, init?: RequestInit): Promise<{ response: Response; body: any }> {
|
||||||
const response = await fetch(`${baseUrl}${pathname}`, init);
|
const headers = new Headers(init?.headers);
|
||||||
|
if (authCookie) headers.set('cookie', authCookie);
|
||||||
|
if (csrfToken && !['GET','HEAD','OPTIONS'].includes((init?.method ?? 'GET').toUpperCase())) headers.set('x-csrf-token', csrfToken);
|
||||||
|
const response = await fetch(`${baseUrl}${pathname}`, { ...init, headers });
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
return { response, body: text ? JSON.parse(text) : undefined };
|
return { response, body: text ? JSON.parse(text) : undefined };
|
||||||
}
|
}
|
||||||
@ -64,6 +69,7 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his
|
|||||||
const backendUrl = `http://127.0.0.1:${backendPort}`;
|
const backendUrl = `http://127.0.0.1:${backendPort}`;
|
||||||
const mockOrigin = `http://localhost:${mockPort}`;
|
const mockOrigin = `http://localhost:${mockPort}`;
|
||||||
const dataDir = mkdtempSync(path.join(tmpdir(), 'conductor-integration-'));
|
const dataDir = mkdtempSync(path.join(tmpdir(), 'conductor-integration-'));
|
||||||
|
authCookie = ''; csrfToken = '';
|
||||||
const observed: Array<{ url: string; headers: IncomingMessage['headers']; body: string }> = [];
|
const observed: Array<{ url: string; headers: IncomingMessage['headers']; body: string }> = [];
|
||||||
|
|
||||||
const mock = createServer(async (req, res) => {
|
const mock = createServer(async (req, res) => {
|
||||||
@ -99,6 +105,8 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his
|
|||||||
processChild.kill('SIGTERM');
|
processChild.kill('SIGTERM');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const bootstrap = spawnSync(process.execPath, [path.resolve(__dirname, '../scripts/bootstrapAdmin.js')], { env: { ...process.env, CONDUCTOR_DATA_DIR: dataDir, CONDUCTOR_BOOTSTRAP_ADMIN_USERNAME: 'integration-admin', CONDUCTOR_BOOTSTRAP_ADMIN_PASSWORD: 'integration-password-123' }, encoding: 'utf8' });
|
||||||
|
assert.equal(bootstrap.status, 0, bootstrap.stderr);
|
||||||
let child = spawnBackend();
|
let child = spawnBackend();
|
||||||
|
|
||||||
t.after(async () => {
|
t.after(async () => {
|
||||||
@ -116,6 +124,12 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his
|
|||||||
assert.equal(health.response.status, 200);
|
assert.equal(health.response.status, 200);
|
||||||
assert.equal(health.body.status, 'ok');
|
assert.equal(health.body.status, 'ok');
|
||||||
|
|
||||||
|
const login = await jsonRequest(backendUrl, '/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'integration-admin', password: 'integration-password-123' }) });
|
||||||
|
assert.equal(login.response.status, 200);
|
||||||
|
csrfToken = login.body.csrfToken;
|
||||||
|
const setCookie = login.response.headers.get('set-cookie') ?? '';
|
||||||
|
authCookie = [`conductor_session=${setCookie.match(/conductor_session=([^;]+)/)?.[1]}`, `conductor_csrf=${setCookie.match(/conductor_csrf=([^;]+)/)?.[1]}`].join('; ');
|
||||||
|
|
||||||
const validDoc = projectDocument();
|
const validDoc = projectDocument();
|
||||||
const validation = await jsonRequest(backendUrl, '/api/projects/validate', {
|
const validation = await jsonRequest(backendUrl, '/api/projects/validate', {
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(validDoc),
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(validDoc),
|
||||||
@ -168,6 +182,28 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his
|
|||||||
assert.equal(afterRestart.response.status, 200);
|
assert.equal(afterRestart.response.status, 200);
|
||||||
assert.deepEqual(JSON.parse(afterRestart.body.project_json), updatedDoc);
|
assert.deepEqual(JSON.parse(afterRestart.body.project_json), updatedDoc);
|
||||||
|
|
||||||
|
// Authentication, CSRF, RBAC, and publication boundaries.
|
||||||
|
const noCsrf = await fetch(`${backendUrl}/api/projects`, { method: 'POST', headers: { cookie: authCookie, 'content-type': 'application/json' }, body: '{}' });
|
||||||
|
assert.equal(noCsrf.status, 403);
|
||||||
|
assert.equal((await noCsrf.json() as any).code, 'CSRF_INVALID');
|
||||||
|
const appUser = await jsonRequest(backendUrl, '/api/admin/users', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'integration-user', password: 'integration-user-password', role: 'user' }) });
|
||||||
|
assert.equal(appUser.response.status, 201);
|
||||||
|
const userLoginResponse = await fetch(`${backendUrl}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'integration-user', password: 'integration-user-password' }) });
|
||||||
|
assert.equal(userLoginResponse.status, 200);
|
||||||
|
const userSetCookie = userLoginResponse.headers.get('set-cookie') ?? '';
|
||||||
|
const userCookie = `conductor_session=${userSetCookie.match(/conductor_session=([^;]+)/)?.[1]}`;
|
||||||
|
assert.equal((await fetch(`${backendUrl}/api/projects`, { headers: { cookie: userCookie } })).status, 403);
|
||||||
|
const publication = await jsonRequest(backendUrl, '/api/admin/published-apps', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sourceProjectId: projectId, slug: 'integration-app', displayName: 'Integration App', description: 'snapshot test', visibility: 'public' }) });
|
||||||
|
assert.equal(publication.response.status, 201, JSON.stringify(publication.body));
|
||||||
|
const savedAdminCookie = authCookie; authCookie = '';
|
||||||
|
const publicRuntime = await jsonRequest(backendUrl, '/api/published-apps/integration-app');
|
||||||
|
authCookie = savedAdminCookie;
|
||||||
|
assert.equal(publicRuntime.response.status, 200);
|
||||||
|
assert.equal(publicRuntime.body.version, 1);
|
||||||
|
assert.equal(publicRuntime.body.document.project.name, 'Updated Project');
|
||||||
|
const unpublished = await jsonRequest(backendUrl, `/api/admin/published-apps/${publication.body.id}/unpublish`, { method: 'POST' });
|
||||||
|
assert.equal(unpublished.response.status, 200);
|
||||||
|
|
||||||
const secretInputs = [
|
const secretInputs = [
|
||||||
['basicAuth', { username: 'alice', password: 'basic-secret' }],
|
['basicAuth', { username: 'alice', password: 'basic-secret' }],
|
||||||
['bearerToken', { token: 'bearer-secret' }],
|
['bearerToken', { token: 'bearer-secret' }],
|
||||||
@ -233,18 +269,18 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his
|
|||||||
assert.ok(history.body.executions.some((entry: any) => entry.outcome === 'upstream_error' && entry.status === 503));
|
assert.ok(history.body.executions.some((entry: any) => entry.outcome === 'upstream_error' && entry.status === 503));
|
||||||
assert.ok(history.body.executions.some((entry: any) => entry.outcome === 'policy_rejected'));
|
assert.ok(history.body.executions.some((entry: any) => entry.outcome === 'policy_rejected'));
|
||||||
|
|
||||||
const cleared = await fetch(`${backendUrl}/api/executions`, { method: 'DELETE' });
|
const cleared = await jsonRequest(backendUrl, '/api/executions', { method: 'DELETE' });
|
||||||
assert.equal(cleared.status, 204);
|
assert.equal(cleared.response.status, 204);
|
||||||
assert.deepEqual((await jsonRequest(backendUrl, '/api/executions')).body.executions, []);
|
assert.deepEqual((await jsonRequest(backendUrl, '/api/executions')).body.executions, []);
|
||||||
|
|
||||||
for (const secretId of secretIds) {
|
for (const secretId of secretIds) {
|
||||||
const deleted = await fetch(`${backendUrl}/api/secrets/${secretId}`, { method: 'DELETE' });
|
const deleted = await jsonRequest(backendUrl, `/api/secrets/${secretId}`, { method: 'DELETE' });
|
||||||
assert.equal(deleted.status, 204);
|
assert.equal(deleted.response.status, 204);
|
||||||
}
|
}
|
||||||
assert.deepEqual((await jsonRequest(backendUrl, '/api/secrets')).body, []);
|
assert.deepEqual((await jsonRequest(backendUrl, '/api/secrets')).body, []);
|
||||||
|
|
||||||
const deletedProject = await fetch(`${backendUrl}/api/projects/${projectId}`, { method: 'DELETE' });
|
const deletedProject = await jsonRequest(backendUrl, `/api/projects/${projectId}`, { method: 'DELETE' });
|
||||||
assert.equal(deletedProject.status, 204);
|
assert.equal(deletedProject.response.status, 204);
|
||||||
assert.equal((await jsonRequest(backendUrl, `/api/projects/${projectId}`)).response.status, 404);
|
assert.equal((await jsonRequest(backendUrl, `/api/projects/${projectId}`)).response.status, 404);
|
||||||
|
|
||||||
// Restore the offline backup and prove the canonical document returns.
|
// Restore the offline backup and prove the canonical document returns.
|
||||||
@ -258,5 +294,5 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his
|
|||||||
const restored = await jsonRequest(backendUrl, `/api/projects/${projectId}`);
|
const restored = await jsonRequest(backendUrl, `/api/projects/${projectId}`);
|
||||||
assert.equal(restored.response.status, 200);
|
assert.equal(restored.response.status, 200);
|
||||||
assert.deepEqual(JSON.parse(restored.body.project_json), updatedDoc);
|
assert.deepEqual(JSON.parse(restored.body.project_json), updatedDoc);
|
||||||
assert.equal((await fetch(`${backendUrl}/api/projects/${projectId}`, { method: 'DELETE' })).status, 204);
|
assert.equal((await jsonRequest(backendUrl, `/api/projects/${projectId}`, { method: 'DELETE' })).response.status, 204);
|
||||||
});
|
});
|
||||||
|
|||||||
17
backend/src/routes/auth.ts
Normal file
17
backend/src/routes/auth.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Router, type Request, type Response } from 'express';
|
||||||
|
import { LocalAuthenticationProvider } from '../lib/localAuthProvider';
|
||||||
|
import { createSession, revokeSession } from '../db/sessions';
|
||||||
|
import { clearCsrfCookie, clearSessionCookie, csrfCookie, optionalAuth, requireAuth, requireCsrf, sessionCookie } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router=Router(); const provider=new LocalAuthenticationProvider();
|
||||||
|
const attempts=new Map<string,{count:number;reset:number}>(); const LIMIT=5, WINDOW=60_000;
|
||||||
|
function attemptKey(req:Request,username:string):string { return `${req.ip}:${username.trim().toLocaleLowerCase('en-US')}`; }
|
||||||
|
router.post('/login',async(req:Request,res:Response)=>{
|
||||||
|
const username=typeof req.body?.username==='string'?req.body.username:''; const password=typeof req.body?.password==='string'?req.body.password:''; const key=attemptKey(req,username); const now=Date.now(); let bucket=attempts.get(key);
|
||||||
|
if(!bucket||bucket.reset<=now){bucket={count:0,reset:now+WINDOW};attempts.set(key,bucket);} if(bucket.count>=LIMIT){res.status(429).json({code:'LOGIN_THROTTLED',error:'Too many login attempts. Try again later.'});return;}
|
||||||
|
const principal=await provider.authenticate({username,password}); if(!principal){bucket.count+=1;res.status(401).json({code:'LOGIN_FAILED',error:'Invalid username or password.'});return;}
|
||||||
|
attempts.delete(key); const tokens=createSession(principal.userId); res.setHeader('Set-Cookie',[sessionCookie(tokens.sessionToken,tokens.expiresAt),csrfCookie(tokens.csrfToken,tokens.expiresAt)]); res.json({user:principal,csrfToken:tokens.csrfToken,expiresAt:tokens.expiresAt});
|
||||||
|
});
|
||||||
|
router.get('/me',optionalAuth,(req,res)=>{ if(!req.principal){res.status(401).json({code:'AUTH_REQUIRED',error:'Authentication is required.'});return;} res.json({user:req.principal}); });
|
||||||
|
router.post('/logout',requireAuth,requireCsrf,(req,res)=>{if(req.sessionToken)revokeSession(req.sessionToken);res.setHeader('Set-Cookie',[clearSessionCookie(),clearCsrfCookie()]);res.status(204).send();});
|
||||||
|
export default router;
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
defaultProjectDocument,
|
defaultProjectDocument,
|
||||||
} from '../db/projects';
|
} from '../db/projects';
|
||||||
import { validateProjectDocument, ValidationIssue } from '../lib/validateProject';
|
import { validateProjectDocument, ValidationIssue } from '../lib/validateProject';
|
||||||
|
import { deleteInactivePublications, hasActivePublication } from '../db/publishedApps';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -119,6 +120,8 @@ router.delete('/:id', (req: Request, res: Response) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasActivePublication(id)) { res.status(409).json({code:'PROJECT_PUBLISHED',error:'Unpublish the active application before deleting its source project.'}); return; }
|
||||||
|
deleteInactivePublications(id);
|
||||||
const deleted = deleteProject(id);
|
const deleted = deleteProject(id);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
res.status(404).json({ error: 'Project not found' });
|
res.status(404).json({ error: 'Project not found' });
|
||||||
|
|||||||
@ -30,7 +30,7 @@ const router = Router();
|
|||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
export const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Headers from the upstream response that are safe to forward to the client.
|
* Headers from the upstream response that are safe to forward to the client.
|
||||||
@ -39,7 +39,7 @@ const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|||||||
*/
|
*/
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface RestActionInput {
|
export interface RestActionInput {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@ -108,7 +108,7 @@ function buildUrl(
|
|||||||
* proxy response regardless of whether the upstream responded with a 2xx or not.
|
* proxy response regardless of whether the upstream responded with a 2xx or not.
|
||||||
* Only throws on genuine network/timeout failures.
|
* Only throws on genuine network/timeout failures.
|
||||||
*/
|
*/
|
||||||
async function executeRequest(action: RestActionInput): Promise<ProxyResponse> {
|
export async function executeRequest(action: RestActionInput): Promise<ProxyResponse> {
|
||||||
const method = (action.method ?? '').toUpperCase();
|
const method = (action.method ?? '').toUpperCase();
|
||||||
const urlTemplate = action.url ?? '';
|
const urlTemplate = action.url ?? '';
|
||||||
const pathParameters = action.pathParameters ?? {};
|
const pathParameters = action.pathParameters ?? {};
|
||||||
|
|||||||
19
backend/src/routes/publishedApps.ts
Normal file
19
backend/src/routes/publishedApps.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Router, type Request, type Response } from 'express';
|
||||||
|
import { getActivePublishedBySlug, listActivePublishedRows, type PublishedAppRow } from '../db/publishedApps';
|
||||||
|
import { executeRequest, ALLOWED_METHODS, type RestActionInput } from './proxy';
|
||||||
|
import { AuthenticationError } from '../lib/authentication';
|
||||||
|
import { ProxyPolicyError, sanitizeUrl } from '../lib/proxyPolicy';
|
||||||
|
import { recordExecution, type ExecutionOutcome } from '../db/executions';
|
||||||
|
|
||||||
|
const router=Router();
|
||||||
|
type Snapshot={schemaVersion:string;project:{id:string;name:string;description?:string;pages:unknown[];actions:RestActionInput[];bindings:unknown[];variables:Record<string,{type:string;defaultValue?:unknown}>;settings?:Record<string,unknown>}};
|
||||||
|
const canAccess=(row:PublishedAppRow,req:Request)=>row.visibility==='public'||!!req.principal;
|
||||||
|
function publicDocument(snapshot:Snapshot):Snapshot { return {...snapshot,project:{...snapshot.project,actions:snapshot.project.actions.map((action)=>{const refs=referencedNames(action);return{id:action.id,name:action.name,description:action.description,method:action.method,url:'published://server-owned',headers:{},queryParameters:{},pathParameters:{},bodyTemplate:'',authenticationType:'anonymous',runtimeInputComponents:[...refs.components],runtimeInputVariables:[...refs.variables]};})}}; }
|
||||||
|
function runtimeDto(row:PublishedAppRow){const snapshot=JSON.parse(row.snapshot_json) as Snapshot;return {slug:row.slug,displayName:row.display_name,description:row.description,visibility:row.visibility,version:row.version,document:publicDocument(snapshot)};}
|
||||||
|
function referencedNames(action:RestActionInput){const components=new Set<string>(),variables=new Set<string>();const visit=(value:unknown)=>{if(typeof value==='string')for(const match of value.matchAll(/\{\{(components|variables)\.([^.}]+)(?:\.value)?\}\}/g))(match[1]==='components'?components:variables).add(match[2]);else if(value&&typeof value==='object')Object.values(value).forEach(visit);};visit(action);return{components,variables};}
|
||||||
|
function renderAction(action:RestActionInput,body:unknown):RestActionInput {const input=body&&typeof body==='object'?body as Record<string,unknown>:{};const componentValues=input.componentValues&&typeof input.componentValues==='object'?input.componentValues as Record<string,unknown>:{};const variableValues=input.variableValues&&typeof input.variableValues==='object'?input.variableValues as Record<string,unknown>:{};const refs=referencedNames(action);for(const key of Object.keys(componentValues))if(!refs.components.has(key))throw new ProxyPolicyError('PUBLISHED_INPUT_FORBIDDEN',`Runtime component value "${key}" is not referenced by this action.`,400);for(const key of Object.keys(variableValues))if(!refs.variables.has(key))throw new ProxyPolicyError('PUBLISHED_INPUT_FORBIDDEN',`Runtime variable "${key}" is not referenced by this action.`,400);const valueText=(value:unknown)=>typeof value==='string'?value:value===undefined?'':JSON.stringify(value);const replace=(value:string)=>value.replace(/\{\{components\.([^.}]+)\.value\}\}/g,(_m,key)=>valueText(componentValues[key])).replace(/\{\{variables\.([^.}]+)\}\}/g,(_m,key)=>valueText(variableValues[key]));const map=(value:Record<string,string>|undefined)=>Object.fromEntries(Object.entries(value??{}).map(([key,val])=>[key,replace(val)]));return{...action,url:replace(action.url??''),headers:map(action.headers),queryParameters:map(action.queryParameters),pathParameters:map(action.pathParameters),bodyTemplate:replace(action.bodyTemplate??'')};}
|
||||||
|
|
||||||
|
router.get('/',(req,res)=>{const rows=listActivePublishedRows().filter((row)=>canAccess(row,req));res.json(rows.map((row)=>({slug:row.slug,displayName:row.display_name,description:row.description,visibility:row.visibility,version:row.version})));});
|
||||||
|
router.post('/:slug/actions/:actionId/execute',async(req:Request,res:Response)=>{const row=getActivePublishedBySlug(req.params.slug);if(!row){res.status(404).json({code:'PUBLISHED_APP_NOT_FOUND',error:'Published application not found.'});return;}if(!canAccess(row,req)){res.status(401).json({code:'AUTH_REQUIRED',error:'Authentication is required.'});return;}const snapshot=JSON.parse(row.snapshot_json) as Snapshot;const action=snapshot.project.actions.find((item)=>item.id===req.params.actionId);if(!action){res.status(404).json({code:'PUBLISHED_ACTION_NOT_FOUND',error:'Published action not found.'});return;}const method=(action.method??'').toUpperCase();if(!ALLOWED_METHODS.has(method)){res.status(400).json({code:'PUBLISHED_ACTION_INVALID',error:'Published action method is invalid.'});return;}const started=Date.now();const base={projectId:snapshot.project.id,projectName:snapshot.project.name,publishedAppId:row.id,publishedVersion:row.version,userId:req.principal?.userId,actionId:action.id,actionName:action.name,method,url:sanitizeUrl(action.url??'')};try{const rendered=renderAction(action,req.body);const result=await executeRequest({...rendered,method});const previewText=typeof result.body==='string'?result.body:JSON.stringify(result.body);const preview=previewText.slice(0,2048);const bytes=Buffer.byteLength(previewText,'utf8');recordExecution({...base,status:result.status,durationMs:result.durationMs,outcome:result.ok?'success':'upstream_error',preview,responseBytes:bytes,previewTruncated:bytes>Buffer.byteLength(preview,'utf8'),contentType:result.headers['content-type']});res.json(result);}catch(error){const auth=error instanceof AuthenticationError;const policy=error instanceof ProxyPolicyError?error:new ProxyPolicyError('PROXY_NETWORK_ERROR','The upstream destination could not be reached.',502);const status=auth?error.status:policy.status;const code=auth?error.code:policy.code;const message=auth?error.message:policy.message;let outcome:ExecutionOutcome='network_error';if(code==='PROXY_TIMEOUT')outcome='timeout';else if(code.includes('TOO_LARGE'))outcome='size_limited';else if(status===400||code.includes('FORBIDDEN')||auth)outcome='policy_rejected';recordExecution({...base,durationMs:Date.now()-started,outcome,errorCode:code,errorMessage:message});res.status(status).json({code,error:message});}});
|
||||||
|
router.get('/:slug',(req,res)=>{const row=getActivePublishedBySlug(req.params.slug);if(!row){res.status(404).json({code:'PUBLISHED_APP_NOT_FOUND',error:'Published application not found.'});return;}if(!canAccess(row,req)){res.status(401).json({code:'AUTH_REQUIRED',error:'Authentication is required.'});return;}res.json(runtimeDto(row));});
|
||||||
|
export default router;
|
||||||
12
backend/src/routes/users.ts
Normal file
12
backend/src/routes/users.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { countAdmins, createLocalUser, getUser, listUsers, updateUser, type UserRole } from '../db/users';
|
||||||
|
import { revokeUserSessions } from '../db/sessions';
|
||||||
|
import { hashPassword, validatePassword } from '../lib/passwords';
|
||||||
|
const router=Router();
|
||||||
|
const role=(value:unknown):value is UserRole=>value==='admin'||value==='user';
|
||||||
|
router.get('/',(_req,res)=>res.json(listUsers()));
|
||||||
|
router.post('/',async(req,res)=>{const {username,password}=req.body??{};const nextRole=role(req.body?.role)?req.body.role:'user';if(typeof username!=='string'||!username.trim()){res.status(400).json({code:'USERNAME_REQUIRED',error:'Username is required.'});return;}if(typeof password!=='string'||validatePassword(password)){res.status(400).json({code:'PASSWORD_INVALID',error:typeof password==='string'?validatePassword(password):'Password is required.'});return;}try{res.status(201).json(createLocalUser(username,await hashPassword(password),nextRole));}catch(error){res.status(409).json({code:'USER_CREATE_FAILED',error:error instanceof Error&&error.message.includes('UNIQUE')?'Username already exists.':'User could not be created.'});}});
|
||||||
|
router.put('/:id',(req,res)=>{const existing=getUser(req.params.id);if(!existing){res.status(404).json({code:'USER_NOT_FOUND',error:'User not found.'});return;}const nextRole=role(req.body?.role)?req.body.role:undefined;const enabled=typeof req.body?.enabled==='boolean'?req.body.enabled:undefined;if(existing.role==='admin'&&existing.enabled&&(nextRole==='user'||enabled===false)&&countAdmins()<=1){res.status(409).json({code:'LAST_ADMIN_REQUIRED',error:'At least one enabled administrator is required.'});return;}const changed=updateUser(existing.id,{role:nextRole,enabled});if(nextRole||enabled===false)revokeUserSessions(existing.id);res.json(changed);});
|
||||||
|
router.post('/:id/reset-password',async(req,res)=>{const password=req.body?.password;if(typeof password!=='string'||validatePassword(password)){res.status(400).json({code:'PASSWORD_INVALID',error:typeof password==='string'?validatePassword(password):'Password is required.'});return;}if(!getUser(req.params.id)){res.status(404).json({code:'USER_NOT_FOUND',error:'User not found.'});return;}const changed=updateUser(req.params.id,{passwordHash:await hashPassword(password)});revokeUserSessions(req.params.id);res.json(changed);});
|
||||||
|
router.post('/:id/revoke-sessions',(req,res)=>{if(!getUser(req.params.id)){res.status(404).json({code:'USER_NOT_FOUND',error:'User not found.'});return;}revokeUserSessions(req.params.id);res.status(204).send();});
|
||||||
|
export default router;
|
||||||
4
backend/src/scripts/bootstrapAdmin.ts
Normal file
4
backend/src/scripts/bootstrapAdmin.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { initDatabase } from '../db/init';
|
||||||
|
import { countAdmins, createLocalUser } from '../db/users';
|
||||||
|
import { hashPassword, validatePassword } from '../lib/passwords';
|
||||||
|
async function main(){initDatabase();const username=process.env.CONDUCTOR_BOOTSTRAP_ADMIN_USERNAME?.trim();const password=process.env.CONDUCTOR_BOOTSTRAP_ADMIN_PASSWORD;if(!username||!password)throw new Error('Set CONDUCTOR_BOOTSTRAP_ADMIN_USERNAME and CONDUCTOR_BOOTSTRAP_ADMIN_PASSWORD.');const issue=validatePassword(password);if(issue)throw new Error(issue);if(countAdmins()>0){console.log('An enabled administrator already exists; no changes made.');return;}createLocalUser(username,await hashPassword(password),'admin');console.log(`Bootstrap administrator "${username}" created. Rotate the supplied password after first login.`);}void main().catch((error)=>{console.error(error instanceof Error?error.message:String(error));process.exitCode=1;});
|
||||||
@ -9,6 +9,8 @@ services:
|
|||||||
- PORT=4000
|
- PORT=4000
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- CONDUCTOR_SECRET_KEY=${CONDUCTOR_SECRET_KEY:-}
|
- CONDUCTOR_SECRET_KEY=${CONDUCTOR_SECRET_KEY:-}
|
||||||
|
# Required when NODE_ENV=production; changing it invalidates active sessions.
|
||||||
|
- CONDUCTOR_SESSION_KEY=${CONDUCTOR_SESSION_KEY:-}
|
||||||
# Optional comma-separated exact origins for narrowly approved private APIs.
|
# Optional comma-separated exact origins for narrowly approved private APIs.
|
||||||
- CONDUCTOR_PROXY_INTERNAL_ORIGINS=${CONDUCTOR_PROXY_INTERNAL_ORIGINS:-}
|
- CONDUCTOR_PROXY_INTERNAL_ORIGINS=${CONDUCTOR_PROXY_INTERNAL_ORIGINS:-}
|
||||||
# Data directory is mounted so the SQLite file survives container restarts.
|
# Data directory is mounted so the SQLite file survives container restarts.
|
||||||
|
|||||||
46
docs/AUTHORIZATION_AND_PUBLISHING_SECURITY.md
Normal file
46
docs/AUTHORIZATION_AND_PUBLISHING_SECURITY.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Authentication, Authorization, and Publishing Security Model
|
||||||
|
|
||||||
|
## Trust boundaries
|
||||||
|
|
||||||
|
Slice 7a introduces three principals: anonymous visitors, authenticated users, and authenticated admins. Authentication establishes a provider-neutral principal; authorization independently checks capabilities derived from the principal's current database role and enabled state.
|
||||||
|
|
||||||
|
The authoring API and published runtime are separate trust boundaries:
|
||||||
|
|
||||||
|
- Admin authoring may submit canonical project/action definitions to validation and test-execution endpoints.
|
||||||
|
- Published clients never submit action definitions. They submit only runtime values to an action ID inside an immutable server-owned snapshot.
|
||||||
|
- Users and anonymous visitors never receive draft project JSON, secret metadata, admin publication metadata, password/session material, or editor APIs.
|
||||||
|
|
||||||
|
## Primary threats and controls
|
||||||
|
|
||||||
|
| Threat | Required control |
|
||||||
|
|---|---|
|
||||||
|
| Password theft | `scrypt` password hashes with unique salts; no plaintext storage/logging; generic login failures |
|
||||||
|
| Brute force | Per-identifier/address bounded login throttling and retry response |
|
||||||
|
| Session theft/fixation | Random opaque server-side sessions, hashed identifiers at rest, rotation at login/role changes, secure cookie attributes |
|
||||||
|
| CSRF | Per-session CSRF token plus same-origin/custom-header verification on cookie-authenticated mutations |
|
||||||
|
| Disabled or demoted account retaining access | Resolve enabled user and current role for each session; revoke all sessions on disable, password reset, or role change |
|
||||||
|
| User reaching authoring APIs | Central admin middleware on projects, validation, secrets, history mutation, authoring proxy, user administration, and publication management |
|
||||||
|
| Draft information disclosure | Separate public runtime DTO from admin publication DTO; never return source project or snapshot through public catalog routes |
|
||||||
|
| Public app invoking stored credentials | Reject publication when a public snapshot contains any non-anonymous action |
|
||||||
|
| Browser substituting URL/auth/secret | Published route resolves the action exclusively from the stored snapshot and accepts only referenced runtime values |
|
||||||
|
| Published template injection | Allow only declared component/variable template tokens and JSON-compatible bounded runtime values; apply existing proxy validation afterward |
|
||||||
|
| Sensitive logging | Redact passwords, cookies, session/CSRF values, credential material, and future identity-provider tokens from responses, logs, and history |
|
||||||
|
| Unsafe bootstrap | Create first admin only through explicit non-default operator input; never log credentials; refuse once an admin exists unless recovery flow applies |
|
||||||
|
|
||||||
|
## Route policy
|
||||||
|
|
||||||
|
- Public: health, login, public catalog/runtime, public published action execution.
|
||||||
|
- Authenticated: current principal, logout, authenticated catalog/runtime and execution.
|
||||||
|
- Admin: every authoring, project, validation, secret, execution-history management, user-management, and publication-management route.
|
||||||
|
|
||||||
|
The backend—not frontend navigation—is the authorization authority.
|
||||||
|
|
||||||
|
## Published snapshot rules
|
||||||
|
|
||||||
|
Publishing validates and stores a canonical JSON snapshot transactionally. Draft saves do not mutate it. Republish validates the replacement before atomically increasing the publication version. Unpublish disables runtime access without deleting the source project.
|
||||||
|
|
||||||
|
Active publications block source-project deletion. Public snapshots must contain only anonymous actions. Authenticated snapshots may contain server-side secret references, but public/runtime representations never return those references.
|
||||||
|
|
||||||
|
## OIDC readiness
|
||||||
|
|
||||||
|
Sessions refer to a stable local user and provider-neutral principal. User records reserve provider/subject identity linkage, while authorization consumes only local user ID, enabled state, and role. Slice 9 can therefore exchange a validated OIDC identity for the same Conductor session without changing RBAC or publication visibility.
|
||||||
@ -22,6 +22,9 @@ This matrix maps every release-critical requirement area in `docs/REQUIREMENTS.m
|
|||||||
| R12 | Preview runtime with loading, empty, success, upstream failure, mapping failure, retry/recovery, and ephemeral state | Slices 2, 5, 6 | Preview suites and all six deterministic manual workflows | Accepted |
|
| R12 | Preview runtime with loading, empty, success, upstream failure, mapping failure, retry/recovery, and ephemeral state | Slices 2, 5, 6 | Preview suites and all six deterministic manual workflows | Accepted |
|
||||||
| R13 | Save/load, portable JSON definition, backend/full-stack restart persistence, runtime reset, and SQLite backup/recovery | Slices 2, 5, 6 | Canonical round-trip tests, E2E save/reload, restart and offline backup/restore evidence | Accepted |
|
| R13 | Save/load, portable JSON definition, backend/full-stack restart persistence, runtime reset, and SQLite backup/recovery | Slices 2, 5, 6 | Canonical round-trip tests, E2E save/reload, restart and offline backup/restore evidence | Accepted |
|
||||||
| R14 | Accessible, scannable Actions & Bindings authoring with progressive disclosure and preserved local drafts | Slice 6a | 25 frontend suites / 520 tests and `SLICE6a_MANUAL_TEST.md` acceptance | Accepted |
|
| R14 | Accessible, scannable Actions & Bindings authoring with progressive disclosure and preserved local drafts | Slice 6a | 25 frontend suites / 520 tests and `SLICE6a_MANUAL_TEST.md` acceptance | Accepted |
|
||||||
|
| R15 | Local authentication, secure sessions, global admin/user RBAC, admin-only authoring, and user lifecycle management | Slice 7a | Backend integration security matrix, frontend regression suite, and accepted manual admin/user workflows | Accepted |
|
||||||
|
| R16 | Immutable standalone published applications with public/authenticated visibility and server-owned published action execution | Slice 7a | Server-snapshot integration coverage and accepted public/restricted publishing workflows | Accepted |
|
||||||
|
| R17 | A fresh installation creates its initial administrator through a secure browser first-run flow without requiring Docker commands | Slice 7b | Atomic setup/security tests, fresh-install browser E2E, recovery verification, and manual acceptance | Planned |
|
||||||
|
|
||||||
## Approved acceptance workflows
|
## Approved acceptance workflows
|
||||||
|
|
||||||
@ -40,6 +43,7 @@ The following capabilities are not release requirements. Their presence in futur
|
|||||||
|
|
||||||
- Provider-neutral AI assistance, including chat, generation, explanation, documentation, and refactoring
|
- Provider-neutral AI assistance, including chat, generation, explanation, documentation, and refactoring
|
||||||
- IBM Bob, watsonx, or any provider-specific AI dependency
|
- IBM Bob, watsonx, or any provider-specific AI dependency
|
||||||
|
- OIDC/enterprise SSO, external identity provisioning, and claim/group mapping (Slice 9)
|
||||||
- Multi-page authoring and advanced page management
|
- Multi-page authoring and advanced page management
|
||||||
- Direct Dropdown-triggered REST execution and general action chaining/orchestration
|
- Direct Dropdown-triggered REST execution and general action chaining/orchestration
|
||||||
- Conditions, branches, loops, parallelism, retries, workflow graphs, and long-running task orchestration
|
- Conditions, branches, loops, parallelism, retries, workflow graphs, and long-running task orchestration
|
||||||
|
|||||||
@ -7,6 +7,12 @@ import Preview from './components/Preview/Preview';
|
|||||||
import ActionInspector from './components/ActionInspector/ActionInspector';
|
import ActionInspector from './components/ActionInspector/ActionInspector';
|
||||||
import { ProjectProvider } from './context/ProjectContext';
|
import { ProjectProvider } from './context/ProjectContext';
|
||||||
import ExecutionHistory from './components/ExecutionHistory/ExecutionHistory';
|
import ExecutionHistory from './components/ExecutionHistory/ExecutionHistory';
|
||||||
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
|
import Login from './components/Auth/Login';
|
||||||
|
import AppCatalog from './components/Published/AppCatalog';
|
||||||
|
import PublishedApp from './components/Published/PublishedApp';
|
||||||
|
import UserManagement from './components/Admin/UserManagement';
|
||||||
|
import Publishing from './components/Admin/Publishing';
|
||||||
|
|
||||||
// Placeholder panels — replaced with real implementations in later steps
|
// Placeholder panels — replaced with real implementations in later steps
|
||||||
function PlaceholderPanel({ title, description }: { title: string; description: string }): React.ReactElement {
|
function PlaceholderPanel({ title, description }: { title: string; description: string }): React.ReactElement {
|
||||||
@ -31,6 +37,8 @@ function AppContent(): React.ReactElement {
|
|||||||
if (activeItem === 'preview') return <Preview />;
|
if (activeItem === 'preview') return <Preview />;
|
||||||
if (activeItem === 'inspector') return <ActionInspector />;
|
if (activeItem === 'inspector') return <ActionInspector />;
|
||||||
if (activeItem === 'history') return <ExecutionHistory />;
|
if (activeItem === 'history') return <ExecutionHistory />;
|
||||||
|
if (activeItem === 'publishing') return <Publishing />;
|
||||||
|
if (activeItem === 'users') return <UserManagement />;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,7 +50,13 @@ function AppContent(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProjectProvider wraps the entire app so all editor views share one context.
|
// ProjectProvider wraps the entire app so all editor views share one context.
|
||||||
function App(): React.ReactElement {
|
function AuthenticatedApp(): React.ReactElement {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const publishedMatch = window.location.pathname.match(/^\/apps\/([^/]+)\/?$/);
|
||||||
|
if (publishedMatch) return <ProjectProvider><PublishedApp slug={decodeURIComponent(publishedMatch[1])}/></ProjectProvider>;
|
||||||
|
if (loading) return <p style={{padding:32}}>Loading…</p>;
|
||||||
|
if (!user) return <Login />;
|
||||||
|
if (user.role === 'user') return <AppCatalog />;
|
||||||
return (
|
return (
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
@ -50,4 +64,6 @@ function App(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App(): React.ReactElement { return <AuthProvider><AuthenticatedApp /></AuthProvider>; }
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
13
frontend/src/api/apiClient.ts
Normal file
13
frontend/src/api/apiClient.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export function csrfToken(): string | undefined {
|
||||||
|
return document.cookie.split('; ').find((part) => part.startsWith('conductor_csrf='))?.split('=').slice(1).join('=');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const method = (init.method ?? 'GET').toUpperCase();
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||||
|
const token = csrfToken();
|
||||||
|
if (token) headers.set('X-CSRF-Token', decodeURIComponent(token));
|
||||||
|
}
|
||||||
|
return fetch(input, { ...init, headers, credentials: 'same-origin' });
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { apiFetch } from './apiClient';
|
||||||
|
|
||||||
export type ExecutionRecord = {
|
export type ExecutionRecord = {
|
||||||
id: number; createdAt: string; projectId: string|null; projectName: string|null;
|
id: number; createdAt: string; projectId: string|null; projectName: string|null;
|
||||||
actionId: string|null; actionName: string|null; method: string; sanitizedUrl: string;
|
actionId: string|null; actionName: string|null; method: string; sanitizedUrl: string;
|
||||||
@ -8,7 +10,7 @@ export type ExecutionRecord = {
|
|||||||
|
|
||||||
export async function listExecutions(filters: {actionId?:string;outcome?:string;status?:string} = {}): Promise<ExecutionRecord[]> {
|
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 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.');
|
const response=await apiFetch(`/api/executions?${query}`); if(!response.ok) throw new Error('Could not load execution history.');
|
||||||
return ((await response.json()) as {executions:ExecutionRecord[]}).executions;
|
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.'); }
|
export async function clearExecutions(): Promise<void> { const response=await apiFetch('/api/executions',{method:'DELETE'}); if(!response.ok) throw new Error('Could not clear execution history.'); }
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { ApiProjectRow, ProjectDocument } from '../types/project';
|
import type { ApiProjectRow, ProjectDocument } from '../types/project';
|
||||||
|
import { apiFetch } from './apiClient';
|
||||||
|
|
||||||
// ── Base URL ──────────────────────────────────────────────────────────────────
|
// ── Base URL ──────────────────────────────────────────────────────────────────
|
||||||
// CRA proxy forwards /api/* to backend:4000 in Docker; in local dev the
|
// CRA proxy forwards /api/* to backend:4000 in Docker; in local dev the
|
||||||
@ -28,13 +29,13 @@ async function handleResponse<T>(res: Response): Promise<T> {
|
|||||||
|
|
||||||
/** List all projects (summary rows — project_json is included but may be large). */
|
/** List all projects (summary rows — project_json is included but may be large). */
|
||||||
export async function listProjects(): Promise<ApiProjectRow[]> {
|
export async function listProjects(): Promise<ApiProjectRow[]> {
|
||||||
const res = await fetch(BASE);
|
const res = await apiFetch(BASE);
|
||||||
return handleResponse<ApiProjectRow[]>(res);
|
return handleResponse<ApiProjectRow[]>(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch a single project by numeric ID. */
|
/** Fetch a single project by numeric ID. */
|
||||||
export async function getProject(id: number): Promise<ApiProjectRow> {
|
export async function getProject(id: number): Promise<ApiProjectRow> {
|
||||||
const res = await fetch(`${BASE}/${id}`);
|
const res = await apiFetch(`${BASE}/${id}`);
|
||||||
return handleResponse<ApiProjectRow>(res);
|
return handleResponse<ApiProjectRow>(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,7 +50,7 @@ export async function createProject(
|
|||||||
description: string,
|
description: string,
|
||||||
doc: ProjectDocument,
|
doc: ProjectDocument,
|
||||||
): Promise<ApiProjectRow> {
|
): Promise<ApiProjectRow> {
|
||||||
const res = await fetch(BASE, {
|
const res = await apiFetch(BASE, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -71,7 +72,7 @@ export async function saveProject(
|
|||||||
description: string,
|
description: string,
|
||||||
doc: ProjectDocument,
|
doc: ProjectDocument,
|
||||||
): Promise<ApiProjectRow> {
|
): Promise<ApiProjectRow> {
|
||||||
const res = await fetch(`${BASE}/${id}`, {
|
const res = await apiFetch(`${BASE}/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RestAction } from '../types/project';
|
import type { RestAction } from '../types/project';
|
||||||
|
import { apiFetch } from './apiClient';
|
||||||
|
|
||||||
// ── Response types ────────────────────────────────────────────────────────────
|
// ── Response types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -29,7 +30,7 @@ export type ProxyResponse = {
|
|||||||
* non-200 response from the proxy itself (e.g. 400 validation error).
|
* non-200 response from the proxy itself (e.g. 400 validation error).
|
||||||
*/
|
*/
|
||||||
export async function executeAction(action: RestAction, project?: {id:string;name:string}): Promise<ProxyResponse> {
|
export async function executeAction(action: RestAction, project?: {id:string;name:string}): Promise<ProxyResponse> {
|
||||||
const res = await fetch('/api/proxy/execute', {
|
const res = await apiFetch('/api/proxy/execute', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({...action,projectId:project?.id,projectName:project?.name}),
|
body: JSON.stringify({...action,projectId:project?.id,projectName:project?.name}),
|
||||||
|
|||||||
9
frontend/src/api/publishedAppsApi.ts
Normal file
9
frontend/src/api/publishedAppsApi.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { apiFetch } from './apiClient';
|
||||||
|
import type { ProjectDocument } from '../types/project';
|
||||||
|
import type { ProxyResponse } from './proxyApi';
|
||||||
|
export type PublishedSummary={slug:string;displayName:string;description:string;visibility:'public'|'authenticated';version:number};
|
||||||
|
export type PublishedRuntime=PublishedSummary&{document:ProjectDocument};
|
||||||
|
async function json<T>(response:Response):Promise<T>{if(!response.ok){const body=await response.json().catch(()=>({})) as {error?:string};throw new Error(body.error??`HTTP ${response.status}`);}return response.json() as Promise<T>;}
|
||||||
|
export const listPublished=()=>apiFetch('/api/published-apps').then(json<PublishedSummary[]>);
|
||||||
|
export const getPublished=(slug:string)=>apiFetch(`/api/published-apps/${encodeURIComponent(slug)}`).then(json<PublishedRuntime>);
|
||||||
|
export const executePublished=(slug:string,actionId:string,componentValues:Record<string,unknown>,variableValues:Record<string,unknown>)=>apiFetch(`/api/published-apps/${encodeURIComponent(slug)}/actions/${encodeURIComponent(actionId)}/execute`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({componentValues,variableValues})}).then(json<ProxyResponse>);
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import type { AuthenticationType } from '../types/project';
|
import type { AuthenticationType } from '../types/project';
|
||||||
|
import { apiFetch } from './apiClient';
|
||||||
|
|
||||||
export type CredentialType = Exclude<AuthenticationType, 'anonymous'>;
|
export type CredentialType = Exclude<AuthenticationType, 'anonymous'>;
|
||||||
export type SecretMetadata = {
|
export type SecretMetadata = {
|
||||||
@ -21,7 +22,7 @@ async function response<T>(res: Response): Promise<T> {
|
|||||||
}
|
}
|
||||||
return res.status === 204 ? undefined as T : res.json() as Promise<T>;
|
return res.status === 204 ? undefined as T : res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
export const listSecrets = async (): Promise<SecretMetadata[]> => response(await fetch(BASE));
|
export const listSecrets = async (): Promise<SecretMetadata[]> => response(await apiFetch(BASE));
|
||||||
export const createSecret = async (name: string, authenticationType: CredentialType, value: SecretValue): Promise<SecretMetadata> => response(await fetch(BASE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, authenticationType, value }) }));
|
export const createSecret = async (name: string, authenticationType: CredentialType, value: SecretValue): Promise<SecretMetadata> => response(await apiFetch(BASE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, authenticationType, value }) }));
|
||||||
export const replaceSecret = async (id: string, name: string, authenticationType: CredentialType, value: SecretValue): Promise<SecretMetadata> => response(await fetch(`${BASE}/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, authenticationType, value }) }));
|
export const replaceSecret = async (id: string, name: string, authenticationType: CredentialType, value: SecretValue): Promise<SecretMetadata> => response(await apiFetch(`${BASE}/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, authenticationType, value }) }));
|
||||||
export const deleteSecret = async (id: string): Promise<void> => response(await fetch(`${BASE}/${id}`, { method: 'DELETE' }));
|
export const deleteSecret = async (id: string): Promise<void> => response(await apiFetch(`${BASE}/${id}`, { method: 'DELETE' }));
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { ProjectDocument } from '../types/project';
|
import type { ProjectDocument } from '../types/project';
|
||||||
|
import { apiFetch } from './apiClient';
|
||||||
|
|
||||||
export type ValidationSeverity = 'error' | 'warning';
|
export type ValidationSeverity = 'error' | 'warning';
|
||||||
export type ValidationIssue = {
|
export type ValidationIssue = {
|
||||||
@ -14,7 +15,7 @@ export type ValidationResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function validateProject(doc: unknown): Promise<ValidationResult> {
|
export async function validateProject(doc: unknown): Promise<ValidationResult> {
|
||||||
const response = await fetch('/api/projects/validate', {
|
const response = await apiFetch('/api/projects/validate', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(doc),
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(doc),
|
||||||
});
|
});
|
||||||
if (response.status === 200 || response.status === 422) {
|
if (response.status === 200 || response.status === 422) {
|
||||||
|
|||||||
16
frontend/src/components/Admin/Publishing.tsx
Normal file
16
frontend/src/components/Admin/Publishing.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { apiFetch } from '../../api/apiClient';
|
||||||
|
import { listProjects } from '../../api/projectsApi';
|
||||||
|
import type { ApiProjectRow } from '../../types/project';
|
||||||
|
|
||||||
|
type Publication = { id:string; slug:string; sourceProjectId:number; displayName:string; description:string; visibility:'public'|'authenticated'; version:number; active:boolean };
|
||||||
|
async function request<T>(url:string,init?:RequestInit):Promise<T>{const r=await apiFetch(url,init);if(!r.ok){const b=await r.json().catch(()=>({})) as {error?:string};throw new Error(b.error??`HTTP ${r.status}`)}return r.json() as Promise<T>}
|
||||||
|
|
||||||
|
export default function Publishing():React.ReactElement {
|
||||||
|
const [projects,setProjects]=React.useState<ApiProjectRow[]>([]),[apps,setApps]=React.useState<Publication[]>([]),[error,setError]=React.useState(''),[projectId,setProjectId]=React.useState(''),[name,setName]=React.useState(''),[slug,setSlug]=React.useState(''),[visibility,setVisibility]=React.useState<'public'|'authenticated'>('authenticated');
|
||||||
|
const load=React.useCallback(()=>Promise.all([listProjects(),request<Publication[]>('/api/admin/published-apps')]).then(([p,a])=>{setProjects(p);setApps(a);setProjectId(current=>current||(p[0]?String(p[0].id):''))}).catch(e=>setError(String(e))),[]);
|
||||||
|
React.useEffect(()=>{void load();},[load]);
|
||||||
|
const publish=async(e:React.FormEvent)=>{e.preventDefault();try{await request('/api/admin/published-apps',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sourceProjectId:Number(projectId),displayName:name,slug,description:'',visibility})});setName('');setSlug('');void load()}catch(x){setError(x instanceof Error?x.message:String(x))}};
|
||||||
|
const action=async(app:Publication,kind:'publish'|'unpublish')=>{try{await request(`/api/admin/published-apps/${app.id}/${kind}`,{method:'POST'});void load()}catch(x){setError(x instanceof Error?x.message:String(x))}};
|
||||||
|
return <section><h2>Publish applications</h2><p>Publishing creates an immutable runtime snapshot. Republish to release the current saved project.</p>{error&&<p role="alert" style={{color:'#cf222e'}}>{error}</p>}<form onSubmit={publish} style={{display:'flex',gap:8,flexWrap:'wrap',padding:16,border:'1px solid #d0d7de',borderRadius:8}}><select required value={projectId} onChange={e=>setProjectId(e.target.value)}>{projects.map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select><input required placeholder="App name" value={name} onChange={e=>setName(e.target.value)}/><input required pattern="[a-z0-9]+(?:-[a-z0-9]+)*" placeholder="url-slug" value={slug} onChange={e=>setSlug(e.target.value)}/><select value={visibility} onChange={e=>setVisibility(e.target.value as typeof visibility)}><option value="authenticated">Restricted to users</option><option value="public">Public (anonymous actions only)</option></select><button>Publish</button></form><div style={{display:'grid',gap:10,marginTop:16}}>{apps.map(app=><div key={app.id} style={{padding:14,border:'1px solid #d0d7de',borderRadius:8}}><strong>{app.displayName}</strong> <code>/apps/{app.slug}</code><p>{app.visibility} · version {app.version} · {app.active?'live':'unpublished'}</p><button onClick={()=>void action(app,'publish')}>{app.active?'Republish':'Publish'}</button>{app.active&&<button onClick={()=>void action(app,'unpublish')} style={{marginLeft:8}}>Unpublish</button>} {app.active&&<a href={`/apps/${app.slug}`} style={{marginLeft:12}}>Open app</a>}</div>)}</div></section>;
|
||||||
|
}
|
||||||
20
frontend/src/components/Admin/UserManagement.tsx
Normal file
20
frontend/src/components/Admin/UserManagement.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { apiFetch } from '../../api/apiClient';
|
||||||
|
|
||||||
|
type User={id:string;username:string;role:'admin'|'user';enabled:boolean;lastLoginAt?:string};
|
||||||
|
async function request<T>(url:string,init?:RequestInit):Promise<T>{const r=await apiFetch(url,init);if(!r.ok){const b=await r.json().catch(()=>({})) as {error?:string};throw new Error(b.error??`HTTP ${r.status}`)}return r.status===204?undefined as T:r.json() as Promise<T>}
|
||||||
|
|
||||||
|
export default function UserManagement():React.ReactElement {
|
||||||
|
const[users,setUsers]=React.useState<User[]>([]),[error,setError]=React.useState(''),[username,setUsername]=React.useState(''),[password,setPassword]=React.useState(''),[confirm,setConfirm]=React.useState(''),[role,setRole]=React.useState<'admin'|'user'>('user'),[resetUser,setResetUser]=React.useState<User|null>(null),[resetPassword,setResetPassword]=React.useState(''),[resetConfirm,setResetConfirm]=React.useState('');
|
||||||
|
const load=()=>request<User[]>('/api/admin/users').then(setUsers).catch(e=>setError(String(e)));
|
||||||
|
React.useEffect(()=>{void load();},[]);
|
||||||
|
const create=async(e:React.FormEvent)=>{e.preventDefault();setError('');if(password!==confirm){setError('Passwords do not match.');return;}try{await request('/api/admin/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,password,role})});setUsername('');setPassword('');setConfirm('');void load()}catch(x){setError(x instanceof Error?x.message:String(x))}};
|
||||||
|
const update=async(user:User,changes:Partial<User>)=>{try{await request(`/api/admin/users/${user.id}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(changes)});void load()}catch(x){setError(x instanceof Error?x.message:String(x))}};
|
||||||
|
const submitReset=async(e:React.FormEvent)=>{e.preventDefault();if(!resetUser)return;setError('');if(resetPassword!==resetConfirm){setError('Passwords do not match.');return;}try{await request(`/api/admin/users/${resetUser.id}/reset-password`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:resetPassword})});setResetUser(null);setResetPassword('');setResetConfirm('')}catch(x){setError(x instanceof Error?x.message:String(x))}};
|
||||||
|
const revoke=(user:User)=>request(`/api/admin/users/${user.id}/revoke-sessions`,{method:'POST'}).catch(e=>setError(String(e)));
|
||||||
|
return <section><h2>User management</h2><p>Create local accounts, assign roles, disable access, and revoke active sessions.</p>{error&&<p role="alert" style={{color:'#cf222e'}}>{error}</p>}
|
||||||
|
<form onSubmit={create} style={{display:'flex',gap:8,flexWrap:'wrap',padding:16,border:'1px solid #d0d7de',borderRadius:8}}><input required aria-label="Username" placeholder="Username" value={username} onChange={e=>setUsername(e.target.value)}/><input required aria-label="Temporary password" type="password" minLength={12} autoComplete="new-password" placeholder="Temporary password (12+ chars)" value={password} onChange={e=>setPassword(e.target.value)}/><input required aria-label="Confirm temporary password" type="password" minLength={12} autoComplete="new-password" placeholder="Confirm password" value={confirm} onChange={e=>setConfirm(e.target.value)}/><select aria-label="New user role" value={role} onChange={e=>setRole(e.target.value as 'admin'|'user')}><option value="user">User</option><option value="admin">Admin</option></select><button>Create user</button></form>
|
||||||
|
<div style={{display:'grid',gap:10,marginTop:16}}>{users.map(user=><div key={user.id} style={{display:'flex',alignItems:'center',gap:12,padding:14,border:'1px solid #d0d7de',borderRadius:8}}><strong style={{minWidth:180}}>{user.username}</strong><select aria-label={`Role for ${user.username}`} value={user.role} onChange={e=>void update(user,{role:e.target.value as User['role']})}><option value="user">User</option><option value="admin">Admin</option></select><label><input type="checkbox" checked={user.enabled} onChange={e=>void update(user,{enabled:e.target.checked})}/> Enabled</label><button onClick={()=>{setError('');setResetUser(user)}}>Reset password</button><button onClick={()=>void revoke(user)}>Revoke sessions</button></div>)}</div>
|
||||||
|
{resetUser&&<div role="dialog" aria-modal="true" aria-labelledby="reset-title" style={{position:'fixed',inset:0,background:'rgba(0,0,0,.45)',display:'grid',placeItems:'center',zIndex:20}}><form onSubmit={submitReset} style={{width:380,maxWidth:'calc(100vw - 48px)',background:'#fff',padding:24,borderRadius:8,boxShadow:'0 8px 32px rgba(0,0,0,.25)'}}><h3 id="reset-title">Reset password for {resetUser.username}</h3><p>Enter the new password twice. It must contain at least 12 characters.</p><label>New password<input autoFocus required type="password" minLength={12} autoComplete="new-password" value={resetPassword} onChange={e=>setResetPassword(e.target.value)} style={{display:'block',width:'100%',boxSizing:'border-box',margin:'6px 0 14px'}}/></label><label>Confirm new password<input required type="password" minLength={12} autoComplete="new-password" value={resetConfirm} onChange={e=>setResetConfirm(e.target.value)} style={{display:'block',width:'100%',boxSizing:'border-box',margin:'6px 0 14px'}}/></label><div style={{display:'flex',gap:8,justifyContent:'flex-end'}}><button type="button" onClick={()=>{setResetUser(null);setResetPassword('');setResetConfirm('')}}>Cancel</button><button>Reset password</button></div></form></div>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
8
frontend/src/components/Auth/Login.tsx
Normal file
8
frontend/src/components/Auth/Login.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
|
export default function Login({ message }: { message?: string }): React.ReactElement {
|
||||||
|
const { login } = useAuth(); const [username,setUsername]=React.useState(''); const [password,setPassword]=React.useState(''); const [error,setError]=React.useState(''); const [busy,setBusy]=React.useState(false);
|
||||||
|
const submit=async(event:React.FormEvent)=>{event.preventDefault();setBusy(true);setError('');try{await login(username,password);}catch(e){setError(e instanceof Error?e.message:'Sign in failed.');}finally{setBusy(false);}};
|
||||||
|
return <main style={{maxWidth:420,margin:'10vh auto',padding:32,border:'1px solid #d0d7de',borderRadius:8,background:'#fff'}}><h1 style={{marginTop:0}}>Sign in to Conductor</h1>{message&&<p>{message}</p>}<form onSubmit={submit}><label>Username<input autoFocus autoComplete="username" value={username} onChange={e=>setUsername(e.target.value)} style={{display:'block',width:'100%',boxSizing:'border-box',padding:10,margin:'6px 0 16px'}} /></label><label>Password<input type="password" autoComplete="current-password" value={password} onChange={e=>setPassword(e.target.value)} style={{display:'block',width:'100%',boxSizing:'border-box',padding:10,margin:'6px 0 16px'}} /></label>{error&&<p role="alert" style={{color:'#cf222e'}}>{error}</p>}<button disabled={busy} style={{padding:'10px 18px'}}>{busy?'Signing in…':'Sign in'}</button></form></main>;
|
||||||
|
}
|
||||||
@ -1,13 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import styles from './Header.module.css';
|
import styles from './Header.module.css';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
function Header(): React.ReactElement {
|
function Header(): React.ReactElement {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<span className={styles.logo}>
|
<span className={styles.logo}>
|
||||||
<span className={styles.logoAccent}>Conductor</span>
|
<span className={styles.logoAccent}>Conductor</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.tagline}>REST UI Builder</span>
|
<span className={styles.tagline}>REST UI Builder</span>
|
||||||
|
<span style={{marginLeft:'auto',fontSize:12,color:'#c9d1d9'}}>{user?.username} ({user?.role === 'admin' ? 'Administrator' : 'User'}) <button onClick={()=>void logout()} style={{marginLeft:12}}>Sign out</button></span>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,4 +10,6 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
{ id: 'preview', label: 'Preview' },
|
{ id: 'preview', label: 'Preview' },
|
||||||
{ id: 'inspector', label: 'Actions & Bindings' },
|
{ id: 'inspector', label: 'Actions & Bindings' },
|
||||||
{ id: 'history', label: 'Execution History' },
|
{ id: 'history', label: 'Execution History' },
|
||||||
|
{ id: 'publishing', label: 'Publishing' },
|
||||||
|
{ id: 'users', label: 'Users' },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { usePreviewRuntime } from './usePreviewRuntime';
|
|||||||
import PreviewComponent from './PreviewComponent';
|
import PreviewComponent from './PreviewComponent';
|
||||||
import styles from './Preview.module.css';
|
import styles from './Preview.module.css';
|
||||||
import ValidationSummary from '../ValidationSummary';
|
import ValidationSummary from '../ValidationSummary';
|
||||||
|
import type { ProjectDocument } from '../../types/project';
|
||||||
|
import type { PreviewActionExecutor } from './usePreviewRuntime';
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -46,8 +48,10 @@ export function expandCanvasBounds(
|
|||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Preview(): React.ReactElement {
|
function Preview({ document, projectName: suppliedName, executor, published = false }: { document?: ProjectDocument; projectName?: string; executor?: PreviewActionExecutor; published?: boolean }): React.ReactElement {
|
||||||
const { doc, projectName } = useProject();
|
const project = useProject();
|
||||||
|
const doc = document ?? project.doc;
|
||||||
|
const projectName = suppliedName ?? project.projectName;
|
||||||
const pages = doc.project.pages;
|
const pages = doc.project.pages;
|
||||||
|
|
||||||
// Page selection (tabs when multiple pages; single page for MVP)
|
// Page selection (tabs when multiple pages; single page for MVP)
|
||||||
@ -96,7 +100,7 @@ function Preview(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Preview runtime (binding execution, component state) ─────────────────
|
// ── Preview runtime (binding execution, component state) ─────────────────
|
||||||
const runtime = usePreviewRuntime(doc);
|
const runtime = usePreviewRuntime(doc, executor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.preview}>
|
<div className={styles.preview}>
|
||||||
@ -104,14 +108,12 @@ function Preview(): React.ReactElement {
|
|||||||
{/* ── Preview header ────────────────────────────────────────── */}
|
{/* ── Preview header ────────────────────────────────────────── */}
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.headerLeft}>
|
<div className={styles.headerLeft}>
|
||||||
<span className={styles.badge}>Preview</span>
|
<span className={styles.badge}>{published ? 'Published app' : 'Preview'}</span>
|
||||||
<span className={styles.projectName}>{projectName}</span>
|
<span className={styles.projectName}>{projectName}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.hint}>
|
{!published && <span className={styles.hint}>Read-only render of the current project. Switch to Visual Editor or JSON Editor to make changes.</span>}
|
||||||
Read-only render of the current project. Switch to Visual Editor or JSON Editor to make changes.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<ValidationSummary />
|
{!published && <ValidationSummary />}
|
||||||
|
|
||||||
{/* ── Page tabs (shown only when more than one page exists) ─── */}
|
{/* ── Page tabs (shown only when more than one page exists) ─── */}
|
||||||
{runtime.pageLoadState.status !== 'idle' && (
|
{runtime.pageLoadState.status !== 'idle' && (
|
||||||
|
|||||||
@ -444,11 +444,17 @@ function applyResponseBindings(
|
|||||||
|
|
||||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime {
|
export type PreviewActionExecutor = (action: RestAction, project: {id:string;name:string}, componentState: Record<string, ComponentRuntimeState>, variableState: VariableRuntimeState) => Promise<ProxyResponse>;
|
||||||
|
|
||||||
|
export function usePreviewRuntime(doc: ProjectDocument, runtimeExecutor: PreviewActionExecutor = executeAction): PreviewRuntime {
|
||||||
const [componentState, setComponentState] = useState<Record<string, ComponentRuntimeState>>({});
|
const [componentState, setComponentState] = useState<Record<string, ComponentRuntimeState>>({});
|
||||||
const [buttonLoading, setButtonLoading] = useState<Record<string, boolean>>({});
|
const [buttonLoading, setButtonLoading] = useState<Record<string, boolean>>({});
|
||||||
const [actionState, setActionState] = useState<ActionRuntimeStateMap>({});
|
const [actionState, setActionState] = useState<ActionRuntimeStateMap>({});
|
||||||
const [pageLoadState, setPageLoadState] = useState<PageLoadRuntimeState>({ status: 'idle' });
|
const [pageLoadState, setPageLoadState] = useState<PageLoadRuntimeState>({ status: 'idle' });
|
||||||
|
const runAction = useCallback((action: RestAction, componentValues: Record<string, ComponentRuntimeState>, variables: VariableRuntimeState) => {
|
||||||
|
const project = {id:doc.project.id,name:doc.project.name};
|
||||||
|
return runtimeExecutor === executeAction ? executeAction(action, project) : runtimeExecutor(action, project, componentValues, variables);
|
||||||
|
}, [doc.project.id, doc.project.name, runtimeExecutor]);
|
||||||
|
|
||||||
// ── Step 18.1: Runtime variable state ─────────────────────────────────────
|
// ── Step 18.1: Runtime variable state ─────────────────────────────────────
|
||||||
// Separate from project.variables — never mutates the canonical document.
|
// Separate from project.variables — never mutates the canonical document.
|
||||||
@ -536,7 +542,7 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
executeAction(renderedAction, {id:doc.project.id,name:doc.project.name})
|
runAction(renderedAction, {}, initialVariableState)
|
||||||
.then((proxyResponse) => {
|
.then((proxyResponse) => {
|
||||||
const updatedActionState: ActionRuntimeStateMap = {
|
const updatedActionState: ActionRuntimeStateMap = {
|
||||||
[actionId]: { response: proxyResponse, loading: false, error: undefined },
|
[actionId]: { response: proxyResponse, loading: false, error: undefined },
|
||||||
@ -705,7 +711,7 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 7. Execute proxy request ──────────────────────────────────────
|
// ── 7. Execute proxy request ──────────────────────────────────────
|
||||||
executeAction(renderedAction, {id:doc.project.id,name:doc.project.name})
|
runAction(renderedAction, componentState, variableState)
|
||||||
.then((proxyResponse) => {
|
.then((proxyResponse) => {
|
||||||
// Store full response envelope in action state
|
// Store full response envelope in action state
|
||||||
const newActionEntry: ActionRuntimeState = {
|
const newActionEntry: ActionRuntimeState = {
|
||||||
@ -791,7 +797,7 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[doc.project, componentState, variableState],
|
[doc.project, componentState, variableState, runAction],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -886,7 +892,7 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
executeAction(renderedAction, {id:doc.project.id,name:doc.project.name})
|
runAction(renderedAction, stateWithSelection, variableState)
|
||||||
.then((proxyResponse) => {
|
.then((proxyResponse) => {
|
||||||
const newActionEntry: ActionRuntimeState = {
|
const newActionEntry: ActionRuntimeState = {
|
||||||
response: proxyResponse,
|
response: proxyResponse,
|
||||||
@ -938,7 +944,7 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[doc.project, componentState, variableState],
|
[doc.project, componentState, variableState, runAction],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTableRowSelect = useCallback(
|
const handleTableRowSelect = useCallback(
|
||||||
|
|||||||
2
frontend/src/components/Published/AppCatalog.tsx
Normal file
2
frontend/src/components/Published/AppCatalog.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import React from 'react'; import { listPublished, type PublishedSummary } from '../../api/publishedAppsApi'; import { useAuth } from '../../context/AuthContext';
|
||||||
|
export default function AppCatalog():React.ReactElement{const{user,logout}=useAuth();const[apps,setApps]=React.useState<PublishedSummary[]>([]);const[error,setError]=React.useState('');React.useEffect(()=>{listPublished().then(setApps).catch(e=>setError(String(e)));},[]);return <main style={{maxWidth:900,margin:'40px auto',padding:24}}><div style={{display:'flex',justifyContent:'space-between'}}><div><h1>Applications</h1><p>Signed in as {user?.displayName}</p></div><button onClick={()=>void logout()} style={{height:38}}>Sign out</button></div>{error&&<p role="alert">{error}</p>}<div style={{display:'grid',gap:16}}>{apps.map(app=><a key={app.slug} href={`/apps/${app.slug}`} style={{display:'block',padding:20,border:'1px solid #d0d7de',borderRadius:8,color:'inherit',textDecoration:'none'}}><strong>{app.displayName}</strong><p>{app.description}</p><small>{app.visibility==='public'?'Public':'Sign-in required'} · version {app.version}</small></a>)}{!apps.length&&!error&&<p>No applications are currently published.</p>}</div></main>}
|
||||||
4
frontend/src/components/Published/PublishedApp.tsx
Normal file
4
frontend/src/components/Published/PublishedApp.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import React from 'react'; import Preview from '../Preview/Preview'; import Login from '../Auth/Login'; import { executePublished, getPublished, type PublishedRuntime } from '../../api/publishedAppsApi'; import { useAuth } from '../../context/AuthContext'; import type { PreviewActionExecutor, ComponentRuntimeState } from '../Preview/usePreviewRuntime';
|
||||||
|
export default function PublishedApp({slug}:{slug:string}):React.ReactElement{const{user,loading}=useAuth();const[app,setApp]=React.useState<PublishedRuntime|null>(null);const[error,setError]=React.useState('');const[needsAuth,setNeedsAuth]=React.useState(false);React.useEffect(()=>{if(loading)return;getPublished(slug).then(value=>{setApp(value);setNeedsAuth(false)}).catch(e=>{const message=e instanceof Error?e.message:String(e);setNeedsAuth(message.includes('Authentication'));setError(message);});},[slug,user,loading]);if(loading)return <p style={{padding:32}}>Loading…</p>;if(needsAuth&&!user)return <Login message="Sign in to open this application."/>;if(error&&!app)return <main style={{padding:32}}><h1>Application unavailable</h1><p>{error}</p></main>;if(!app)return <p style={{padding:32}}>Loading application…</p>;
|
||||||
|
const executor:PreviewActionExecutor=async(action,_project,componentState,variableState)=>{const metadata=action as typeof action&{runtimeInputComponents?:string[];runtimeInputVariables?:string[]};const wantedComponents=new Set(metadata.runtimeInputComponents??[]),wantedVariables=new Set(metadata.runtimeInputVariables??[]);const componentValues:Record<string,unknown>={};for(const page of app.document.project.pages)for(const component of page.components)if(wantedComponents.has(component.name)){const state=componentState[component.id] as ComponentRuntimeState|undefined;componentValues[component.name]=state?.textValue??state?.value??component.properties.defaultValue??'';}const variables=Object.fromEntries(Object.entries(variableState).filter(([key])=>wantedVariables.has(key)));return executePublished(slug,action.id,componentValues,variables);};
|
||||||
|
return <main><div style={{padding:'12px 24px',display:'flex',justifyContent:'space-between',borderBottom:'1px solid #d0d7de'}}><strong>{app.displayName}</strong>{user&&<span>Signed in as {user.displayName}</span>}</div><Preview document={app.document} projectName={app.displayName} executor={executor} published/></main>}
|
||||||
18
frontend/src/context/AuthContext.tsx
Normal file
18
frontend/src/context/AuthContext.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { apiFetch } from '../api/apiClient';
|
||||||
|
|
||||||
|
export type CurrentUser = { id: string; username: string; displayName: string; role: 'admin' | 'user' };
|
||||||
|
type AuthValue = { user: CurrentUser | null; loading: boolean; login: (username: string, password: string) => Promise<void>; logout: () => Promise<void>; refresh: () => Promise<void> };
|
||||||
|
const AuthContext = React.createContext<AuthValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||||
|
const [user, setUser] = React.useState<CurrentUser | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const normalize=(value:CurrentUser):CurrentUser=>({...value,id:value.id??(value as CurrentUser&{userId:string}).userId,displayName:value.displayName??value.username});
|
||||||
|
const refresh = React.useCallback(async () => { const response = await apiFetch('/api/auth/me'); setUser(response.ok ? normalize(((await response.json()) as { user: CurrentUser }).user) : null); setLoading(false); }, []);
|
||||||
|
React.useEffect(() => { void refresh(); }, [refresh]);
|
||||||
|
const login = async (username: string, password: string) => { const response = await apiFetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (!response.ok) throw new Error('Invalid username or password.'); setUser(normalize(((await response.json()) as { user: CurrentUser }).user)); };
|
||||||
|
const logout = async () => { await apiFetch('/api/auth/logout', { method: 'POST' }); setUser(null); };
|
||||||
|
return <AuthContext.Provider value={{ user, loading, login, logout, refresh }}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
export function useAuth(): AuthValue { const value = React.useContext(AuthContext); if (!value) throw new Error('AuthProvider is missing.'); return value; }
|
||||||
@ -48,7 +48,7 @@ for (const file of linkedFiles) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let id = 1; id <= 14; id += 1) {
|
for (let id = 1; id <= 17; id += 1) {
|
||||||
if (!traceability.includes(`| R${id} |`)) failures.push(`docs/MVP_TRACEABILITY.md: missing R${id}`);
|
if (!traceability.includes(`| R${id} |`)) failures.push(`docs/MVP_TRACEABILITY.md: missing R${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,4 +57,4 @@ if (failures.length > 0) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('MVP governance check passed: scope, traceability, classifications, and links are consistent.');
|
console.log('MVP governance check passed: scope, R1-R17 traceability, classifications, and links are consistent.');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user