Release Conductor 1.1.0 with independent application packages
@ -14,3 +14,7 @@ SLICE*
|
||||
SAVE_POINT.md
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Local build copies and release evidence
|
||||
.local/
|
||||
release/
|
||||
|
||||
@ -4,7 +4,7 @@ CONDUCTOR_SECRET_KEY=replace-with-64-hex-characters
|
||||
CONDUCTOR_SESSION_KEY=replace-with-an-independent-random-value-at-least-32-bytes
|
||||
|
||||
CONDUCTOR_IMAGE=gitea.skeletonworks.online/vwiebe/conductor
|
||||
CONDUCTOR_VERSION=v1.0.0
|
||||
CONDUCTOR_VERSION=v1.1.0
|
||||
CONDUCTOR_BIND_ADDRESS=127.0.0.1
|
||||
CONDUCTOR_PORT=8080
|
||||
CONDUCTOR_MEM_LIMIT=1g
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
name: Release production image
|
||||
|
||||
# Releases are built, tested and pushed once by scripts/release-image.sh.
|
||||
# Keep this opt-in workflow for explicit publisher use; tags do not trigger a second build.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Existing immutable source tag (vX.Y.Z)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
production-image:
|
||||
@ -13,6 +18,8 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.version }}
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to Gitea registry
|
||||
@ -24,8 +31,11 @@ jobs:
|
||||
- name: Resolve release metadata
|
||||
id: metadata
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
version="${GITHUB_REF_NAME:-manual}"
|
||||
version="$RELEASE_VERSION"
|
||||
[[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1
|
||||
revision="$(git rev-parse HEAD)"
|
||||
short_revision="$(git rev-parse --short=12 HEAD)"
|
||||
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
4
.gitignore
vendored
@ -53,3 +53,7 @@ backend/data/*.db-shm
|
||||
|
||||
# IBM Bob generated reports
|
||||
.bob/artifacts/
|
||||
|
||||
# Local build copies and release evidence
|
||||
.local/
|
||||
release/
|
||||
|
||||
@ -303,7 +303,7 @@ Proportional manual acceptance passed on 2026-08-01 in isolated Chromium against
|
||||
|
||||
## Slice 3 Encrypted Secret Lifecycle Increment
|
||||
|
||||
On 2026-08-01, the user approved AES-256-GCM encryption in SQLite with a server-only 32-byte master key and opaque reference-only canonical JSON. The initial backend increment added an encrypted secret table, metadata-only create/list/lookup/update/delete APIs, internal-only resolution, and structured fail-closed key configuration behavior. `docs/SECRETS.md` records the threat assumptions and key lifecycle.
|
||||
On 2026-08-01, the user approved AES-256-GCM encryption in SQLite with a server-only 32-byte master key and opaque reference-only canonical JSON. The initial backend increment added an encrypted secret table, metadata-only create/list/lookup/update/delete APIs, internal-only resolution, and structured fail-closed key configuration behavior. `docs/ADMIN_GUIDE.md` and `docs/OPERATIONS.md` record the threat assumptions and key lifecycle.
|
||||
|
||||
The backend TypeScript build passed. A controlled Docker check created a disposable Bearer credential and returned metadata without its token. A plaintext scan did not find the test token in SQLite. Metadata survived a backend restart using the same key. Deletion returned HTTP 204 and subsequent lookup returned HTTP 404. The disposable record was removed, and the disposable key was removed from the running configuration. Canonical references, authoring UI, credential injection, compatibility validation, comprehensive automated tests, and redaction remain pending.
|
||||
|
||||
|
||||
47
CODEX.md
@ -20,11 +20,11 @@ Use these files in this order:
|
||||
1. Current source code and automated tests
|
||||
2. `ROADMAP.md` for completed and remaining work
|
||||
3. `TESTING.md` for pending and accepted manual workflows
|
||||
4. `docs/response-mapping-model.md` for response-binding decisions
|
||||
4. `docs/SCHEMA.md` for response-binding decisions
|
||||
5. `shared/schemas/conductor-project.schema.json` for the accepted project-document shape
|
||||
6. `docs/REQUIREMENTS.md` for product scope
|
||||
7. `docs/ARCHITECTURE.md` for architectural direction
|
||||
8. `docs/BUILD_AND_TEST_PLAN.md` for historical milestone and validation guidance
|
||||
6. `internal-docs/REQUIREMENTS.md` for product scope
|
||||
7. `internal-docs/ARCHITECTURE.md` for architectural direction
|
||||
8. `internal-docs/BUILD_AND_TEST_PLAN.md` for historical milestone and validation guidance
|
||||
|
||||
The `docs/` copies of requirements, architecture, and future-idea specifications are authoritative. Exact duplicate root copies were removed during the roadmap consolidation.
|
||||
|
||||
@ -311,7 +311,7 @@ Date: 2026-07-20
|
||||
### Slice 3 Encrypted Secret Lifecycle Addendum
|
||||
|
||||
- The user approved AES-256-GCM credential encryption in SQLite with a server-only 32-byte master key and opaque canonical references.
|
||||
- The backend now has encrypted secret persistence, metadata-only lifecycle APIs, internal-only credential resolution, and fail-closed missing/malformed-key handling. The threat and lifecycle contract is in `docs/SECRETS.md`.
|
||||
- The backend now has encrypted secret persistence, metadata-only lifecycle APIs, internal-only credential resolution, and fail-closed missing/malformed-key handling. The threat and lifecycle contract is in `docs/ADMIN_GUIDE.md` and `docs/OPERATIONS.md`.
|
||||
- Backend TypeScript build passed. A controlled Docker lifecycle check returned no credential value, found no plaintext token in SQLite, survived restart, and passed deletion/404 cleanup. The disposable test record and active disposable key were removed.
|
||||
- Canonical `secretReferenceId`, protected-mode frontend authoring, runtime injection for all four credential-backed modes, structured compatibility errors, and reflected-response credential redaction are implemented. Controlled echo checks passed for every mode; full frontend coverage remains 21 suites / 507 tests, focused authoring coverage passes at 2 suites / 32 tests, and frontend/backend builds pass.
|
||||
- Guided secret lifecycle UI, compatible action selection, reference-aware deletion, and durable encryption tests are implemented. Backend tests/build, full frontend coverage at 21 suites / 507 tests, focused authoring at 2 suites / 32 tests, and the frontend production build pass.
|
||||
@ -334,7 +334,7 @@ Date: 2026-07-20
|
||||
- Slice 6a implementation and automation are complete through the manual gate: accessible collapsible sections/cards, compact record summaries, grouped REST editing, explicit secret creation, visible collapsed issue counts, and mounted-state preservation are implemented. Focused coverage passes at 18 tests; full frontend coverage passes at 25 suites / 520 tests; TypeScript and production build pass. `Slice 6a Density Manual` (`#22`) was prepared for the local Slice 6a manual procedure.
|
||||
- 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.
|
||||
- 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. `internal-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.
|
||||
|
||||
### 2026-08-08 v1.0.0 Production Release Preparation Addendum
|
||||
@ -343,3 +343,38 @@ Date: 2026-07-20
|
||||
- The canonical project-document schema remains `0.1.0`; no stored-project or publication-snapshot migration is part of the application-version promotion.
|
||||
- Release-facing documentation, package metadata, Compose/environment defaults, Linux/AMD64 publication settings, and the SkeletonWorks default image were aligned to `v1.0.0`.
|
||||
- The `v1.0.0` source tag, container image, Gitea Release entry, artifact digests/checksums, and final external/manual acceptance remain pending and must not be reported as complete before publication.
|
||||
|
||||
### 2026-09-12 Projects and installed applications
|
||||
|
||||
- User authorized finishing the home/Projects screens and adding a generic Included with Skeleton Works section to Publishing. Builder projects and installer-owned applications stay separate.
|
||||
- Added an empty-by-default SQLite registry, authenticated read API, trusted container registration/list/removal CLI, reusable frontend catalog, and role filtering. No Social Scheduler seed or hardcoded entry exists in base Conductor.
|
||||
- Projects supports create/open/preview/rename/delete and selecting a project for Publishing; metadata edits retain canonical content and reject stale rows. Existing publish/republish/unpublish behavior is preserved.
|
||||
- The Social Scheduler 0.4.0 installer supplies its own manifest after health checks, backs up Conductor SQLite, and bundles this source. setup-conductor.sh needs no change. Nothing was deployed or published to an image registry in this session.
|
||||
- Added scripts/test-management.mjs for isolated browser acceptance and docs/INCLUDED_APPLICATIONS.md for future installers. Preserve the pre-existing documentation reorganization and unrelated working-tree edits.
|
||||
- Local verification passed: 29 backend tests, 528 frontend tests, production builds, isolated management browser checks, integrated Social Scheduler Open/Configure links, documentation links and governance. Full customer Docker deployment remains untested here because this environment has no Docker daemon.
|
||||
|
||||
### 2026-09-13 Temporary Conductor favicon
|
||||
|
||||
- User requested a minimal, possibly temporary Conductor favicon first, followed by browser-title and default/per-application favicon work. Skeleton Works branding and the separate SaaS backlog remain outside this step.
|
||||
- Created design/icon/conductor.svg: white C and raised baton on the existing interface blue #165BBA. Added transparent PNG exports at 16, 32, 48, 180, 192 and 512 pixels, a multi-size 16/32/48 ICO, a preview page/image and an asset README.
|
||||
- Verified image dimensions and transparency, decoded every preview image and the ICO in Chromium, and visually inspected the icon at browser-tab size on light and dark backgrounds.
|
||||
- Assets are not yet connected to frontend/public, application titles, installed-app metadata or published applications. No release kit or customer VM was changed in this icon-design step.
|
||||
|
||||
### 2026-09-13 SLICE10 — Browser titles and application icons
|
||||
|
||||
- The user renamed the four follow-up phases SLICE10–SLICE13 and requested instruction/log files in this repository, then authorized SLICE10 implementation. Each file now contains scope, checklists, acceptance purposes, progress and handoff information.
|
||||
- SLICE10 local implementation is complete: approved default favicon assets, app-first browser titles, shared auth-aware identity, optional installed-app iconPath, and published-app PNG upload/change/reset with matching card icons.
|
||||
- Conductor SQLite stores public decorative icon bytes and publication selections; normal startup adds the nullable column. Canonical project schema 0.1.0 is unchanged. PNG decoding and administrative upload requests are bounded.
|
||||
- Publication metadata updates now validate the existing snapshot, preventing draft edits from either exposing credential-bearing publications or unnecessarily blocking an icon change.
|
||||
- Final local checks pass: 29 backend tests; 29 frontend suites / 531 tests; frontend TypeScript and production builds; 26 browser identity checks; management browser acceptance; the actual Social Scheduler integrated frontend and authenticated bridge with a read-only API fixture; visual review; documentation, governance and whitespace checks.
|
||||
- The current Social Scheduler manifest inherits Social Scheduler · Conductor and the default Conductor icon. Base Conductor remains independent of Social Scheduler.
|
||||
- SLICE11–SLICE13 remain planned. No release, customer VM deployment, setup-conductor.sh change or upgrade is included in SLICE10. The earlier 2026-09-12 note that setup-conductor.sh needed no change applied only to that Social Scheduler installer integration; the standalone release/setup/upgrader follow-up is now explicitly tracked.
|
||||
- See SLICE10.md for implementation details, resolved test setup issues, artifacts, limits and the next handoff. Do not present local validation as customer acceptance or release publication.
|
||||
|
||||
### 2026-09-13 SLICE11 — Independent application packages and release
|
||||
|
||||
- User authorized SLICE11 including publication. Implemented host API 1 with verified persistent browser bundles, generic controlled API routes, encrypted connections, independent package updates, and backup/restore of installed assets.
|
||||
- Social Scheduler 0.5.0 installs its own package through the host CLI; it no longer patches Conductor source or rebuilds a combined image. A second independent app exercises the same contract.
|
||||
- Local source, browser, visual, Node-RED/PostgreSQL and hardened production container checks pass. Exact publication identity and final external verification are recorded in SLICE11.md and the Gitea v1.1.0 release entry when complete.
|
||||
- The source release includes the accepted home/Projects/catalog and SLICE10 identity changes. Preserve the pre-existing documentation reorganization.
|
||||
- SLICE12 setup-conductor.sh and SLICE13 customer-VM migration remain separate; no customer deployment or real social post was performed in SLICE11.
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:20-alpine AS frontend-build
|
||||
FROM node:24-alpine AS frontend-build
|
||||
WORKDIR /build/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS backend-build
|
||||
FROM node:24-alpine AS backend-build
|
||||
RUN apk add --no-cache python3 make g++
|
||||
WORKDIR /build/backend
|
||||
COPY backend/package*.json ./
|
||||
@ -14,7 +14,7 @@ RUN npm ci
|
||||
COPY backend/ ./
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
FROM node:24-alpine AS runtime
|
||||
ARG VERSION=development
|
||||
ARG REVISION=unknown
|
||||
ARG CREATED=unknown
|
||||
|
||||
@ -87,4 +87,4 @@ The MVP is complete only when all required component, configuration, authenticat
|
||||
|
||||
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 `internal-docs/MVP_TRACEABILITY.md`.
|
||||
|
||||
31
README.md
@ -79,13 +79,13 @@ docker compose up --build
|
||||
|
||||
Conductor's production distribution is one non-root container serving the compiled frontend, published application routes, and backend API on port 8080. SQLite data is stored in a persistent volume. The development stack above remains separate.
|
||||
|
||||
The command below is the final production coordinate and will succeed only after the public `v1.0.0` Gitea Release and container image have been published. Until then, a missing-manifest response means publication is incomplete; do not substitute `latest` or private registry credentials.
|
||||
The command below pulls the immutable public v1.1.0 production image. Do not substitute `latest` for a controlled installation.
|
||||
|
||||
```bash
|
||||
docker pull gitea.skeletonworks.online/vwiebe/conductor:v1.0.0
|
||||
docker pull gitea.skeletonworks.online/vwiebe/conductor:v1.1.0
|
||||
```
|
||||
|
||||
The v1.0.0 production image supports Linux/AMD64. Anonymous source and image availability are mandatory publication checks. A complete internet-facing installation also requires persistent generated keys, the tagged Compose bundle, a data volume, and HTTPS.
|
||||
The v1.1.0 production image supports Linux/AMD64. Anonymous source and image availability are mandatory publication checks. A complete internet-facing installation also requires persistent generated keys, the tagged Compose bundle, a data volume, and HTTPS.
|
||||
|
||||
Independent self-hosters should follow [docs/INSTALL.md](docs/INSTALL.md) from start to finish. For backup, restore, upgrade, and rollback, see [docs/OPERATIONS.md](docs/OPERATIONS.md). Conductor never ships a default username or password.
|
||||
|
||||
@ -93,29 +93,18 @@ Independent self-hosters should follow [docs/INSTALL.md](docs/INSTALL.md) from s
|
||||
|
||||
## Documentation
|
||||
|
||||
Start with the document for your role. Most people need only one guide; cross-links lead to the few procedures that require another.
|
||||
|
||||
| Document | Description |
|
||||
|---|---|
|
||||
| [ROADMAP.md](ROADMAP.md) | Unified v1.0.0 work plan grouped by function and slice |
|
||||
| [MVP_SCOPE.md](MVP_SCOPE.md) | Approved MVP and v1.0.0 release boundary |
|
||||
| [TESTING.md](TESTING.md) | Detailed manual test workflows and acceptance records |
|
||||
| [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) | Full product requirements |
|
||||
| [docs/MVP_TRACEABILITY.md](docs/MVP_TRACEABILITY.md) | v1.0.0 requirements mapped to slices and validation evidence |
|
||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Technical architecture |
|
||||
| [docs/NICE-TO-HAVE.md](docs/NICE-TO-HAVE.md) | Future enhancements |
|
||||
| [docs/BUILD_AND_TEST_PLAN.md](docs/BUILD_AND_TEST_PLAN.md) | Incremental build and test plan |
|
||||
| [docs/INSTALL.md](docs/INSTALL.md) | Public source/container and reverse-proxy installation |
|
||||
| [docs/OPERATIONS.md](docs/OPERATIONS.md) | Production configuration, backup, restore, upgrade, and rollback |
|
||||
| [docs/RELEASE.md](docs/RELEASE.md) | Image tagging, release construction, and publication |
|
||||
| [docs/RELEASE_NOTES_v1.0.0.md](docs/RELEASE_NOTES_v1.0.0.md) | v1.0.0 capabilities, compatibility, and publication record requirements |
|
||||
| [docs/RELEASE_NOTES_v1.1.0.md](docs/RELEASE_NOTES_v1.1.0.md) | v1.1.0 capabilities, compatibility, limitations, and artifact identities |
|
||||
| [docs/TUTORIAL.md](docs/TUTORIAL.md) | Guided first project covering authoring, REST actions, bindings, Preview, saving, and publishing |
|
||||
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Project authoring and published-application use |
|
||||
| [docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md) | Users, sessions, credentials, publishing, and execution history |
|
||||
| [docs/FIRST_RUN_SETUP.md](docs/FIRST_RUN_SETUP.md) | First-administrator setup and recovery entry points |
|
||||
| [docs/SECRETS.md](docs/SECRETS.md) | Credential encryption and key lifecycle |
|
||||
| [docs/PROXY_SECURITY.md](docs/PROXY_SECURITY.md) | REST destination, request, response, and history policy |
|
||||
| [docs/AUTHORIZATION_AND_PUBLISHING_SECURITY.md](docs/AUTHORIZATION_AND_PUBLISHING_SECURITY.md) | Authentication, RBAC, and publication trust boundaries |
|
||||
| [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md) | Architecture, invariants, tests, and contribution workflow |
|
||||
| [docs/SCHEMA.md](docs/SCHEMA.md) | Canonical project JSON format, bindings, and validation |
|
||||
| [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Production and application troubleshooting |
|
||||
| [docs/KNOWN_LIMITATIONS.md](docs/KNOWN_LIMITATIONS.md) | Explicit v1.0.0 limitations and deferred capabilities |
|
||||
|
||||
---
|
||||
|
||||
@ -127,3 +116,7 @@ Independent self-hosters should follow [docs/INSTALL.md](docs/INSTALL.md) from s
|
||||
| Backend | Node.js 20, Express 4, TypeScript 5 |
|
||||
| Database | SQLite (MVP) |
|
||||
| Deployment | Single production container, Docker Compose, Caddy/NGINX reverse proxy |
|
||||
|
||||
## Independent applications in v1.1.0
|
||||
|
||||
Conductor hosts trusted application packages from its persistent data volume. Installers add browser files and declared API routes without rebuilding the core image. Social Scheduler 0.5.0 uses this interface. See [Application packages](docs/APP_PACKAGES.md), [release notes](docs/RELEASE_NOTES_v1.1.0.md), and the [SLICE11 log](SLICE11.md). Fresh setup and the existing-VM upgrader remain in SLICE12 and SLICE13.
|
||||
|
||||
@ -349,3 +349,12 @@ Slice 8 release packaging now depends on completed Slice 7a authentication/RBAC/
|
||||
- The older roadmap's completed persistence checkpoint records working functional round trips; comprehensive automated persistence round-trip coverage remains a Slice 6 release task.
|
||||
- Slice 7's status is complete because its product decisions were approved. Its unchecked cross-document consistency criteria are carried forward under Slices 6 and 8 rather than treated as a new scope decision.
|
||||
- Passing an incremental automated suite does not complete a slice. Manual acceptance and final release gates remain separately recorded.
|
||||
|
||||
## Conductor platform follow-up — SLICE10–SLICE13
|
||||
|
||||
The original slices above remain historical. The 2026-09-13 follow-up uses these instructions and progress logs:
|
||||
|
||||
- [SLICE10: browser titles and application icons](SLICE10.md) — implemented and locally verified; release/customer VM acceptance pending.
|
||||
- [SLICE11: independent release and installed-app packages](SLICE11.md) — implemented and locally verified; publication in progress.
|
||||
- [SLICE12: fresh-install setup script](SLICE12.md) — planned.
|
||||
- [SLICE13: idempotent upgrades](SLICE13.md) — planned.
|
||||
|
||||
103
SLICE10.md
Normal file
@ -0,0 +1,103 @@
|
||||
# SLICE10 — Browser titles and application icons
|
||||
|
||||
Status: Complete — implementation and local verification; release/customer VM acceptance pending
|
||||
|
||||
## Purpose
|
||||
|
||||
Implement the approved temporary Conductor favicon and browser-title convention in the application itself. Support optional icons for installed applications and applications published from Conductor projects.
|
||||
|
||||
## Working instructions
|
||||
|
||||
Read CODEX.md and applicable AGENTS.md instructions before work. Current source and tests are authoritative. Preserve unrelated working-tree changes. Use this file as both the slice specification and its continuing log.
|
||||
|
||||
The user authorized implementation of this slice on 2026-09-13. Update the checklist and append dated log entries as work proceeds. Record decisions, changed files, checks and results, failures, unresolved issues and the next action. Do not erase earlier log entries or claim VM/release acceptance from local tests alone.
|
||||
|
||||
## Scope
|
||||
|
||||
- [x] Install the approved vector and fallback favicon assets as Conductor defaults.
|
||||
- [x] Use app-first titles: Conductor at home; Projects · Conductor and Publishing · Conductor for core views; Social Scheduler · Conductor and each published app's display name for app routes.
|
||||
- [x] Use a shared identity mechanism that handles navigation, direct links, loading, sign-in/sign-out and unavailable applications without leaving stale titles or icons.
|
||||
- [x] Allow installed-app manifests to specify an optional local icon; existing manifests keep working.
|
||||
- [x] Provide optional custom icons for published applications, stored on the customer's VM with a default-icon reset.
|
||||
- [x] Use the relevant icon on application cards as well as in the browser tab.
|
||||
- [x] Keep default fallback behavior when an icon is omitted, missing or unusable.
|
||||
- [x] Update administrator, user and installer-contract documentation.
|
||||
|
||||
## Acceptance checks
|
||||
|
||||
| Action | Purpose / expected behavior |
|
||||
|---|---|
|
||||
| Open the home page and core screens | Correct title and Conductor icon; no stale identity after navigation. |
|
||||
| Open an installed app directly and from its card | Registered application name and optional icon apply, with no Social Scheduler hardcoding in base Conductor. |
|
||||
| Publish an app with and without a custom icon | Both published routes and catalog cards use the selected icon or default. |
|
||||
| Change and reset an app icon, then reload | Selection persists, and reset restores the default. |
|
||||
| Try a missing or invalid icon and sign out | Fallback and authorization remain correct; private app metadata is not disclosed before authentication. |
|
||||
| Run focused tests, regressions and production builds | Verify actual behavior and compatibility; inspect light/dark browser-size screenshots. |
|
||||
|
||||
## Boundaries and dependencies
|
||||
|
||||
The temporary icon artwork is approved in design/icon. Plugin-package architecture, image publication, setup-script changes and upgrades belong to SLICE11–SLICE13. No customer deployment is part of this slice.
|
||||
|
||||
The customer control panel and other SaaS backlog items remain outside these four slices. The user reports only one live Conductor instance, which has Social Scheduler installed. A clean Conductor reset is an available fallback if a safe retrofit is impractical; first record the exact scope and backup/recovery plan. This does not authorize wiping the entire VM or deleting Node-RED's saved connections or PostgreSQL's schedules.
|
||||
|
||||
## Progress log
|
||||
|
||||
### 2026-09-13 — Plan recorded
|
||||
|
||||
- Renamed the proposed four phases to SLICE10, SLICE11, SLICE12 and SLICE13, respectively.
|
||||
- Implementation started: inspecting existing publication metadata, authentication and installed-app registration.
|
||||
|
||||
## Initial handoff (superseded by the completion log below)
|
||||
|
||||
Implement and verify browser identity and optional app icons; append the actual results here.
|
||||
|
||||
### 2026-09-13 — Implementation progress
|
||||
|
||||
- Added shared browser identity, approved default assets, optional local iconPath in installed-app manifests, and application-card icons.
|
||||
- Added PNG upload/selection/reset in Publishing. Images are constrained to square 16–512 pixel PNGs up to 256 KiB, decoded and re-encoded, and stored as deduplicated public decorative assets in Conductor SQLite.
|
||||
- Existing publication metadata gets a nullable icon_path column automatically; old manifests and apps retain the default. No canonical project-schema change is needed.
|
||||
- The existing 29 backend tests and 528 frontend tests pass after initial implementation. Adding focused API and browser coverage before marking implementation verified.
|
||||
- Dependency: pinned pngjs 7.0.0 and its type declarations; consulted the project's primary documentation at https://github.com/pngjs/pngjs. No native image library or new VM service is required.
|
||||
|
||||
### 2026-09-13 — Implementation and local verification complete
|
||||
|
||||
- Installed the approved SVG, ICO, 32-pixel PNG and touch-icon assets in frontend/public. The SVG and ICO match their approved design masters byte-for-byte.
|
||||
- Connected shared identity to core navigation, signed-in application catalogs, published app runtime and the installed-app registry. Titles use the agreed app-first convention. Default fallback also applies after an image error, a five-second load timeout, navigation or sign-out.
|
||||
- Published apps support optional PNG upload on creation, later icon changes and default-icon reset. App cards display the same image. Icon changes are metadata changes, independent of the immutable project snapshot.
|
||||
- Added backward-compatible optional iconPath metadata to schemaVersion 1 installed-app manifests. Base Conductor has no Social Scheduler-specific title or icon.
|
||||
- Added authenticated administrative upload and public decorative asset routes. PNG parsing checks file size, chunk structure, dimensions, CRC and bounded decompression; images are re-encoded and deduplicated in SQLite. Old publications receive a nullable icon choice through repeatable initialization.
|
||||
- Fixed a concrete publication boundary found during this work: metadata changes now validate the existing published snapshot rather than the current draft. A private credential-bearing snapshot cannot become public just because its draft was edited, and a safe public app can change its icon even if its unpublished draft now needs authentication.
|
||||
- Updated administrator, user, installer-contract, installation, operations, test and icon-asset documentation.
|
||||
- Preserved the pre-existing Projects/registry work and unrelated documentation/schema/runtime changes. The pre-slice source backup is retained at /home/vwiebe/projects/.local/conductor-slice10-start-20260913T144004Z.
|
||||
|
||||
Verification performed by Codex in local Ubuntu/Chromium using disposable databases and Node 24.18.1:
|
||||
|
||||
| Check | Result / evidence |
|
||||
|---|---|
|
||||
| Backend build and tests | PASS — 29 tests, including real HTTP/SQLite icon validation, permissions, restart persistence and published-snapshot regressions. |
|
||||
| Frontend tests | PASS — 29 suites / 531 tests, including delayed icon-load, sign-out and timeout regressions. |
|
||||
| Frontend TypeScript and production build | PASS. |
|
||||
| Browser identity acceptance | PASS — 26 identity assertions plus upload/reset/republish, missing-asset fallback and default asset checks. Results: test-results/slice10/browser-identity.json. |
|
||||
| Existing management browser acceptance | PASS — Projects, publishing, registry updates/removal, role filtering and failure recovery. |
|
||||
| Current Social Scheduler integration | PASS — compiled the actual source overlay with the final Conductor changes; registered its real manifest twice; opened Configure and Compose through the authenticated bridge; confirmed Social Scheduler · Conductor and the default icon; return home restores Conductor. |
|
||||
| Visual inspection | PASS — Publishing at 1440 and 1000 pixels, plus default icons at 16/32/48 pixels on light and dark backgrounds. No overlaps observed. |
|
||||
| Documentation, governance and whitespace | PASS — documentation links, MVP governance and git diff --check. |
|
||||
|
||||
Browser artifacts are under test-results/slice10, test-results/management and test-results/slice10-social-integration. The compatibility build is /home/vwiebe/projects/.local/conductor-slice10-social-kit/.local/conductor-build. Its scheduler API is a read-only local fixture; no Bluesky post or customer credential was used.
|
||||
|
||||
Resolved verification issues:
|
||||
|
||||
- The first browser selector expected the Access label without its option text. Corrected the test selector; the real publishing workflow then passed.
|
||||
- The first compatibility-copy location was inside its source directory, which Node correctly refused to copy recursively. Prepared the kit as a sibling under projects/.local and rebuilt successfully.
|
||||
- The first race test used a test helper that this repository does not install. Reused its existing React act/createRoot approach; no testing dependency was added.
|
||||
- The documentation scan included an incomplete temporary source backup. Moved that backup outside Conductor rather than changing the documentation checker; the scan then passed.
|
||||
|
||||
### Delivery boundaries
|
||||
|
||||
Local implementation and verification are complete. Customer VM acceptance and release publication have not occurred in SLICE10. setup-conductor.sh, customer data, social credentials, the Social Scheduler release archive and version numbers were not changed. No source commit, push or image publication was made.
|
||||
|
||||
User-uploaded icons support PNG in this slice; trusted installed-app assets can use the documented local image formats. Uploaded images are public decorative assets and are retained when a selection is reset. Unused-asset cleanup and per-app touch icons are not implemented.
|
||||
|
||||
### Next action
|
||||
|
||||
Proceed with SLICE11 when requested: independently release Conductor and replace source-overlay app packaging with the generic installed-app package interface. SLICE12 then updates fresh installation; SLICE13 supplies the repeatable upgrade path. Preserve the completed title/icon behavior and its tests during that work.
|
||||
74
SLICE11.md
Normal file
@ -0,0 +1,74 @@
|
||||
# SLICE11 — Independent Conductor release and installed application packages
|
||||
|
||||
Status: In progress
|
||||
|
||||
## Purpose
|
||||
|
||||
Release an independently installable Conductor with the completed management and identity features, and finish separation of installed-app code from the core container image.
|
||||
|
||||
## Working instructions
|
||||
|
||||
Read CODEX.md and applicable AGENTS.md instructions before work. Current source and tests are authoritative. Preserve unrelated working-tree changes. Use this file as both the slice specification and its continuing log.
|
||||
|
||||
The user authorized SLICE11 on 2026-09-13. SLICE12 and SLICE13 remain separate. Update the checklist and append dated log entries as work proceeds. Record decisions, changed files, checks and results, failures, unresolved issues and the next action. Do not erase earlier log entries or claim VM/release acceptance from local tests alone.
|
||||
|
||||
## Scope
|
||||
|
||||
- [ ] Define a versioned installed-application package containing frontend assets and the metadata needed for discovery and controlled API access.
|
||||
- [ ] Keep installed application files in persistent storage separate from the core Conductor image.
|
||||
- [ ] Provide a generic host/integration mechanism that preserves Conductor authentication and backend authorization; do not allow arbitrary user-supplied backend code.
|
||||
- [ ] Adapt Social Scheduler to install/register its own application package without editing Conductor source or rebuilding a special Conductor image.
|
||||
- [ ] Maintain the customer-domain behavior and existing /social-scheduler links where practical.
|
||||
- [ ] Prove the interface with a small second test application; do not implement the future customer control panel yet.
|
||||
- [ ] Verify a fresh core installation has an empty Included with Skeleton Works list and no Social Scheduler dependency.
|
||||
- [ ] Build a versioned release, publish source/artifacts/container image using the existing release conventions, record digests and verify the published artifacts.
|
||||
- [ ] Document compatibility and migration from the current combined Conductor/Social Scheduler image.
|
||||
|
||||
## Acceptance checks
|
||||
|
||||
| Action | Purpose / expected behavior |
|
||||
|---|---|
|
||||
| Start standalone Conductor without Social Scheduler | Core management, titles and favicon function independently; the included-app list is empty. |
|
||||
| Install Social Scheduler and a second test app | Both apps register and run without patching core source or rebuilding its image. |
|
||||
| Restart/recreate the core container | Application packages and metadata persist. |
|
||||
| Exercise admin, ordinary-user and anonymous requests | Authentication and API permissions remain enforced for each app. |
|
||||
| Fetch the published release and inspect its version/digest | Release artifacts match the tested source and contain SLICE10 changes. |
|
||||
|
||||
## Boundaries and dependencies
|
||||
|
||||
Depends on SLICE10. Preserve the earlier completed slices and their logs.
|
||||
|
||||
The customer control panel and other SaaS backlog items remain outside these four slices. The user reports only one live Conductor instance, which has Social Scheduler installed. A clean Conductor reset is an available fallback if a safe retrofit is impractical; first record the exact scope and backup/recovery plan. This does not authorize wiping the entire VM or deleting Node-RED's saved connections or PostgreSQL's schedules.
|
||||
|
||||
## Progress log
|
||||
|
||||
### 2026-09-13 — Plan recorded
|
||||
|
||||
- Renamed the proposed four phases to SLICE10, SLICE11, SLICE12 and SLICE13, respectively.
|
||||
- Implementation has not started.
|
||||
|
||||
## Current handoff
|
||||
|
||||
Implementation and local validation are complete. Preparing the exact source commit and published artifacts; customer-VM migration remains SLICE13.
|
||||
|
||||
### 2026-09-13 — Implementation started
|
||||
|
||||
- Preserved a pre-slice backup under /home/vwiebe/projects/.local/conductor-slice11-start.
|
||||
- Chosen host API v1: verified browser asset packages, persistent under Conductor data, with a declarative allowlisted API bridge. No installed backend JavaScript is executed by Conductor.
|
||||
- Preserve /social-scheduler and /api/social-scheduler. Connection secrets stay server-side; social credentials remain in Node-RED.
|
||||
- Release target: Conductor v1.1.0 and Social Scheduler 0.5.0. A fresh core has no seeded apps.
|
||||
- Existing setup/upgrader scripts are not changed in this slice. The new scheduler installer will require the compatible core before it changes customer state.
|
||||
|
||||
### 2026-09-13 — Implementation and local acceptance
|
||||
|
||||
- Implemented manifest version 1 / host API 1, persistent verified browser packages, atomic active-version selection, encrypted fixed backend connections, controlled API routes and signed-in runtime context.
|
||||
- Added the generic package CLI, application loader, and package-aware backup v2; restore still accepts backup v1. A second independent example proves the interface without Social Scheduler-specific core code.
|
||||
- Social Scheduler 0.5.0 builds its own browser package. Its installer checks host compatibility first, installs through the CLI and preserves the Conductor image and Compose definition.
|
||||
- Runtime dependencies were updated compatibly, including Express's qs override. Backend production audit: zero vulnerabilities. Existing CRA development/build advisories remain a documented separate toolchain follow-up.
|
||||
- Source checks passed: 29 backend tests; 29 frontend suites / 531 tests; frontend TypeScript and production build; schema matrix 15 valid / 2 expected-invalid / 2 diagnostic-invalid; documentation links and governance.
|
||||
- Social Scheduler checks passed: typecheck, 37 core tests, 15 installer tests and real Node-RED/PostgreSQL integration. An initial direct integration invocation lacked its disposable database; rerunning through the supported wrapper passed.
|
||||
- Browser acceptance passed: 26 identity checks, management workflows, actual independently packaged Social Scheduler, second app, admin/user/anonymous rules, CSRF, bearer delegation, fixed identity/workspace values, query filtering, bounded responses, callback privacy, encrypted connections, update/restart/removal and invalid package rejection.
|
||||
- Visually inspected Publishing with two installed apps and the independently mounted Social Scheduler compose screen. Layout and icons are correct; screenshots are under test-results/slice11.
|
||||
- Docker was initially unavailable; the user restarted it. Production checks then found that docker cp cannot target /tmp in the read-only container. Changed installer transfer to private stdin-fed files created by the running non-root process, and the lifecycle fixture to stream tar into its writable temporary area.
|
||||
- Real hardened-container checks now pass: package installation twice leaves the image unchanged; recreation retains the app; backup/restore recovers the database and deliberately removed package assets.
|
||||
- No customer VM, social credential, scheduler queue, setup-conductor.sh or upgrade script was changed.
|
||||
49
SLICE12.md
Normal file
@ -0,0 +1,49 @@
|
||||
# SLICE12 — Fresh-install setup-conductor.sh update
|
||||
|
||||
Status: Planned
|
||||
|
||||
## Purpose
|
||||
|
||||
Update Skeleton Works' Conductor setup process to install the new standalone release and provide persistent installed-app storage.
|
||||
|
||||
## Working instructions
|
||||
|
||||
Read CODEX.md and applicable AGENTS.md instructions before work. Current source and tests are authoritative. Preserve unrelated working-tree changes. Use this file as both the slice specification and its continuing log.
|
||||
|
||||
This slice is planned; do not start it as part of SLICE10. Update the checklist and append dated log entries as work proceeds. Record decisions, changed files, checks and results, failures, unresolved issues and the next action. Do not erase earlier log entries or claim VM/release acceptance from local tests alone.
|
||||
|
||||
## Scope
|
||||
|
||||
- [ ] Update /home/vwiebe/projects/skeletonworks-scripts/setup-conductor.sh to use the verified SLICE11 release.
|
||||
- [ ] Create/mount the persistent storage expected by the generic installed-app host.
|
||||
- [ ] Preserve customer URL discovery, Docker-based builds/runtime, persistent keys and existing first-run account setup.
|
||||
- [ ] Deliver favicon/title behavior through the Conductor release; do not patch frontend source from the setup script.
|
||||
- [ ] Keep Conductor mandatory in the SaaS offering while leaving Social Scheduler unregistered until its installer succeeds.
|
||||
- [ ] Give clear guidance for existing installations so fresh setup is not confused with the SLICE13 upgrade process.
|
||||
- [ ] Update installation and handover guides, including sudo for protected discovery/dry-run operations.
|
||||
|
||||
## Acceptance checks
|
||||
|
||||
| Action | Purpose / expected behavior |
|
||||
|---|---|
|
||||
| Run setup on a fresh customer-style VM | The selected release, default favicon, titles and management screens are present. |
|
||||
| Inspect URLs, volumes and first-run setup | They match the customer setup result and persist after restart. |
|
||||
| Open Publishing before and after app installation | No false app entry appears before its installer succeeds. |
|
||||
| Install without host Node.js | The Docker-based installation works without adding Node to the VM. |
|
||||
|
||||
## Boundaries and dependencies
|
||||
|
||||
Depends on SLICE11. Preserve the earlier completed slices and their logs.
|
||||
|
||||
The customer control panel and other SaaS backlog items remain outside these four slices. The user reports only one live Conductor instance, which has Social Scheduler installed. A clean Conductor reset is an available fallback if a safe retrofit is impractical; first record the exact scope and backup/recovery plan. This does not authorize wiping the entire VM or deleting Node-RED's saved connections or PostgreSQL's schedules.
|
||||
|
||||
## Progress log
|
||||
|
||||
### 2026-09-13 — Plan recorded
|
||||
|
||||
- Renamed the proposed four phases to SLICE10, SLICE11, SLICE12 and SLICE13, respectively.
|
||||
- Implementation has not started.
|
||||
|
||||
## Current handoff
|
||||
|
||||
Await completion of the preceding slice and the instruction to proceed.
|
||||
52
SLICE13.md
Normal file
@ -0,0 +1,52 @@
|
||||
# SLICE13 — Idempotent Conductor upgrade process
|
||||
|
||||
Status: Planned
|
||||
|
||||
## Purpose
|
||||
|
||||
Provide one reusable upgrade process that preserves customer data, configuration and installed applications and can be rerun safely.
|
||||
|
||||
## Working instructions
|
||||
|
||||
Read CODEX.md and applicable AGENTS.md instructions before work. Current source and tests are authoritative. Preserve unrelated working-tree changes. Use this file as both the slice specification and its continuing log.
|
||||
|
||||
This slice is planned; do not start it as part of SLICE10. Update the checklist and append dated log entries as work proceeds. Record decisions, changed files, checks and results, failures, unresolved issues and the next action. Do not erase earlier log entries or claim VM/release acceptance from local tests alone.
|
||||
|
||||
## Scope
|
||||
|
||||
- [ ] Choose a shared upgrade implementation exposed through an upgrade mode or update-conductor.sh; avoid accumulating one-off numbered patch scripts.
|
||||
- [ ] Inspect current installation and target compatibility before changing live state; support a read-only dry run.
|
||||
- [ ] Back up Conductor data, keys, configuration and installed-app state before mutation.
|
||||
- [ ] Preserve users, passwords/keys, projects, publications, app packages, registry records and API connection settings.
|
||||
- [ ] Apply supported migrations once and keep source-independent installed apps functional during core upgrades.
|
||||
- [ ] Use locking and repeatable steps so interrupted or repeated runs do not duplicate apps, regenerate credentials or reset settings.
|
||||
- [ ] Check health and representative application routes after upgrade.
|
||||
- [ ] Provide and verify recovery to the previous working image/data combination.
|
||||
- [ ] Document update, retry, compatibility-failure and recovery procedures.
|
||||
|
||||
## Acceptance checks
|
||||
|
||||
| Action | Purpose / expected behavior |
|
||||
|---|---|
|
||||
| Upgrade the existing supported installation | Customer data, identity and installed applications remain available. |
|
||||
| Repeat the same upgrade | No duplicate entries, unnecessary key rotation or configuration reset occurs. |
|
||||
| Interrupt a controlled test upgrade and rerun | The process resumes safely or exits with an actionable recovery path. |
|
||||
| Simulate failed health/compatibility checks | Live state is preserved or the documented recovery path restores service. |
|
||||
| Run dry-run with protected setup records | sudo permits inspection while no application state changes. |
|
||||
|
||||
## Boundaries and dependencies
|
||||
|
||||
Depends on SLICE12. Preserve the earlier completed slices and their logs.
|
||||
|
||||
The customer control panel and other SaaS backlog items remain outside these four slices. The user reports only one live Conductor instance, which has Social Scheduler installed. A clean Conductor reset is an available fallback if a safe retrofit is impractical; first record the exact scope and backup/recovery plan. This does not authorize wiping the entire VM or deleting Node-RED's saved connections or PostgreSQL's schedules.
|
||||
|
||||
## Progress log
|
||||
|
||||
### 2026-09-13 — Plan recorded
|
||||
|
||||
- Renamed the proposed four phases to SLICE10, SLICE11, SLICE12 and SLICE13, respectively.
|
||||
- Implementation has not started.
|
||||
|
||||
## Current handoff
|
||||
|
||||
Await completion of the preceding slice and the instruction to proceed.
|
||||
@ -67,7 +67,7 @@ Execute every MVP authentication type securely without exposing credentials to t
|
||||
## Progress Log
|
||||
|
||||
- 2026-08-01: The user approved AES-256-GCM encryption in SQLite using a server-only 32-byte master key supplied through Docker/environment configuration. Canonical JSON will carry opaque secret references only.
|
||||
- 2026-08-01: Added the encrypted secret table, metadata-only CRUD API, internal-only resolution path, structured fail-closed key configuration behavior, and `docs/SECRETS.md` threat/lifecycle contract.
|
||||
- 2026-08-01: Added the encrypted secret table, metadata-only CRUD API, internal-only resolution path, structured fail-closed key configuration behavior, and the security contract now consolidated into `docs/ADMIN_GUIDE.md` and `docs/OPERATIONS.md`.
|
||||
- 2026-08-01: Backend TypeScript build passed. A controlled Docker lifecycle check created a disposable Bearer credential, returned metadata only, found no plaintext token in SQLite, preserved metadata across backend restart, deleted the record with HTTP 204, and confirmed HTTP 404 afterward. The disposable master key was removed from the running configuration after the check.
|
||||
- 2026-08-01: Added canonical `secretReferenceId`, protected-mode authoring, server-side Basic/Bearer/API-key header/API-key query injection, type compatibility checks, and recursive response-body credential redaction. Controlled httpbingo checks returned HTTP 200 for all four modes while reflected Authorization, header-key, query-key, and URL values were `[REDACTED]`. Missing references and type mismatches returned structured HTTP 400 errors. Full frontend coverage passed at 21 suites / 507 tests, focused authoring coverage passed at 2 suites / 32 tests, and frontend/backend production builds passed.
|
||||
- 2026-08-01: Added guided secret creation and replacement with masked credential inputs, metadata-only listing, compatible-secret selection for actions, and deletion protection for unsaved and saved project references. Added durable AES-256-GCM round-trip, missing-key, and wrong-key backend tests. Backend tests/build, 21 frontend suites / 507 tests, focused 2 suites / 32 tests, and the frontend production build passed.
|
||||
|
||||
52
TESTING.md
@ -85,9 +85,9 @@ The user granted explicit final Slice 6a sign-off on 2026-08-07 with the exact w
|
||||
|
||||
## Slice 7 MVP Governance Reconciliation — 2026-08-07
|
||||
|
||||
The already approved Slice 7 product decisions were reconciled across `README.md`, `MVP_SCOPE.md`, `ROADMAP.md`, `docs/REQUIREMENTS.md`, and `docs/ARCHITECTURE.md`. Stale AI-MVP claims and material open questions were removed; implemented proxy, secrets, authentication, observability, canonical-model, timeout, event, binding, and editor decisions now use consistent language. All six `MVP_SCOPE.md` workflow checklists reflect accepted Slice 6 evidence.
|
||||
The already approved Slice 7 product decisions were reconciled across `README.md`, `MVP_SCOPE.md`, `ROADMAP.md`, `internal-docs/REQUIREMENTS.md`, and `internal-docs/ARCHITECTURE.md`. Stale AI-MVP claims and material open questions were removed; implemented proxy, secrets, authentication, observability, canonical-model, timeout, event, binding, and editor decisions now use consistent language. All six `MVP_SCOPE.md` workflow checklists reflect accepted Slice 6 evidence.
|
||||
|
||||
`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.
|
||||
`internal-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.
|
||||
|
||||
@ -728,3 +728,51 @@ scripts/production/restore-conductor.sh --list
|
||||
```
|
||||
|
||||
The release gate includes a disposable destructive restore drill with a marker record, verification that the restored service runs as UID 10001 with a read-only root filesystem, and refresh checks for `/apps/:appSlug/:pageSlug`.
|
||||
|
||||
## Management and included-application acceptance
|
||||
|
||||
After building backend and frontend, run **node scripts/test-management.mjs** from the repository root. It starts a disposable local server and SQLite database, exercises the real built screens in Chromium, and removes the test database afterward. Screenshots are in test-results/management.
|
||||
|
||||
The checks cover empty home/catalog states, project creation/rename/search/preview/delete, unsaved-change confirmation, publish/republish/unpublish, protection of published source projects, registry upserts/removal, admin versus user discovery, and recovery after a failed catalog request. Backend integration tests cover registry validation, authentication, restart persistence and stale project edits.
|
||||
|
||||
## SLICE10 — Browser identity and optional icons
|
||||
|
||||
Build the backend and frontend with the repository's supported Node environment before these browser checks. The tests use disposable SQLite data and local servers, not a customer VM or social-media account.
|
||||
|
||||
node scripts/test-browser-identity.mjs
|
||||
node scripts/test-management.mjs
|
||||
|
||||
The identity check exercises first-run setup, core screen names, published app names, PNG upload/reset/republish, anonymous and restricted access, installed-app route matching, missing assets, and sign-in/sign-out. It writes screenshots and a JSON result to test-results/slice10. The management check covers the existing Projects and Publishing workflows.
|
||||
|
||||
Frontend unit coverage in BrowserIdentity.test.tsx checks that delayed image loads cannot overwrite a newer app identity or the signed-out default. It also checks failed and timed-out image fallback. Backend integration coverage includes PNG bounds and format checks, duplicate PNG headers and excessive decompression, role/CSRF enforcement, asset deduplication and restart persistence, nullable icon metadata, republish preservation, and published-snapshot access checks.
|
||||
|
||||
The existing Social Scheduler source-overlay integration can be checked separately:
|
||||
|
||||
node scripts/test-social-identity.mjs /absolute/path/to/prepared-conductor-build /absolute/path/to/social-scheduler
|
||||
|
||||
This expects an already-built combined frontend/backend and the scheduler's integration/included-app.json. It supplies a read-only local scheduler API fixture, registers the actual manifest twice, opens the real Configure and Compose screens, and checks the inherited Social Scheduler title/default icon and return-home reset. It does not post to Bluesky. Screenshots go to test-results/slice10-social-integration.
|
||||
|
||||
Local review checklist:
|
||||
|
||||
| Action | Why / expected result |
|
||||
|---|---|
|
||||
| Open Publishing at desktop and compact widths | Icon selection and application-card controls remain legible without overlap. |
|
||||
| Inspect the default icon on light and dark backgrounds | The temporary mark remains recognizable at browser-tab size. |
|
||||
| Open Social Scheduler from Publishing and return home | The tab changes between Social Scheduler · Conductor and Conductor. |
|
||||
| Upload a test PNG, publish, reset the icon, then reload the app | The chosen icon persists and reset restores the shared default. |
|
||||
|
||||
Record local automation, visual inspection and customer acceptance separately in [SLICE10.md](SLICE10.md). Passing local checks does not constitute release publication or VM upgrade acceptance.
|
||||
|
||||
## SLICE11 — Independent application host
|
||||
|
||||
Build the backend and frontend, and build the scheduler package with npm run package:app from its source directory. Then run:
|
||||
|
||||
node scripts/test-installed-apps.mjs /absolute/path/to/social-scheduler
|
||||
|
||||
This uses disposable SQLite data, Chromium and a local backend fixture. It proves that a second application and the actual scheduler frontend can be installed, updated, removed and restarted without patching or rebuilding the host. API checks cover admin/user/anonymous access, CSRF, bearer delegation, workspace/path/query restrictions, trusted identity headers, callback privacy and bounded responses. Artifacts: test-results/slice11.
|
||||
|
||||
The production lifecycle suite now backs up and restores an installed example package and recreates the core container with persistent data. It uses a disposable Docker Compose project and cleans it up. Run against the exact release image:
|
||||
|
||||
CONDUCTOR_TEST_IMAGE=gitea.skeletonworks.online/vwiebe/conductor:v1.1.0 bash scripts/test-production.sh --skip-build
|
||||
|
||||
The legacy source-overlay test-social-identity.mjs remains historical compatibility evidence for SLICE10. Current package releases use test-installed-apps.mjs.
|
||||
|
||||
57
backend/package-lock.json
generated
@ -1,24 +1,26 @@
|
||||
{
|
||||
"name": "conductor-backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "conductor-backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-sqlite3": "^12.0.0",
|
||||
"express": "^4.19.2",
|
||||
"morgan": "^1.10.0"
|
||||
"morgan": "^1.10.0",
|
||||
"pngjs": "7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.14.2",
|
||||
"@types/pngjs": "6.0.5",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
@ -183,6 +185,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pngjs": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz",
|
||||
"integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
@ -436,9 +448,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"version": "1.20.8",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
|
||||
"integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
@ -448,7 +461,7 @@
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"qs": "~6.16.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
@ -857,9 +870,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -869,7 +882,8 @@
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
@ -1360,9 +1374,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/morgan": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz",
|
||||
"integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==",
|
||||
"version": "1.12.1",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz",
|
||||
"integrity": "sha512-tljKC0ex20AjO58Ob/eZ53JloycbVswbVNCHx6V6VLGzqt/w8dIynVGL0G8qVjNKwiA7sYSogCrN6QtJ82IV+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"basic-auth": "~2.0.1",
|
||||
@ -1509,6 +1523,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
@ -1560,9 +1583,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "conductor-backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
@ -15,17 +15,24 @@
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-sqlite3": "^12.0.0",
|
||||
"express": "^4.19.2",
|
||||
"morgan": "^1.10.0"
|
||||
"morgan": "^1.10.0",
|
||||
"pngjs": "7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.14.2",
|
||||
"@types/pngjs": "6.0.5",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"allowScripts": {
|
||||
"better-sqlite3@12.11.1": true
|
||||
},
|
||||
"overrides": {
|
||||
"express": {
|
||||
"qs": "6.16.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import healthRouter from './routes/health';
|
||||
import {installedApiRouter,installedRuntimeRouter,installedAssetRouter} from './routes/installedApps';
|
||||
import projectsRouter from './routes/projects';
|
||||
import validateRouter from './routes/validate';
|
||||
import proxyRouter from './routes/proxy';
|
||||
@ -13,14 +14,15 @@ 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';
|
||||
import includedAppsRouter from './routes/includedApps';
|
||||
import {appIconsRouter, uploadAppIconsRouter} from './routes/appIcons';
|
||||
import { optionalAuth, requireAuth, requireAdmin, requireMutationCsrf } from './middleware/auth';
|
||||
|
||||
const app: Application = express();
|
||||
|
||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(morgan('dev'));
|
||||
app.disable('x-powered-by');
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
@ -29,11 +31,20 @@ app.use((_req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Installed API traffic can contain callback codes; handle it before access logging.
|
||||
app.use(installedApiRouter);
|
||||
app.use(morgan('dev'));
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.use('/api/health', healthRouter);
|
||||
app.use('/api/installed-apps',installedRuntimeRouter);
|
||||
app.use('/installed-app-assets',installedAssetRouter);
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/published-apps', optionalAuth, publishedAppsRouter);
|
||||
app.use('/api/included-apps', requireAuth, includedAppsRouter);
|
||||
app.use('/api/app-icons', requireAdmin, requireMutationCsrf, uploadAppIconsRouter);
|
||||
app.use('/app-icons', appIconsRouter);
|
||||
app.use('/api/admin/users', requireAdmin, requireMutationCsrf, usersRouter);
|
||||
app.use('/api/admin/published-apps', requireAdmin, requireMutationCsrf, adminPublishedAppsRouter);
|
||||
app.use('/api/projects/validate', requireAdmin, requireMutationCsrf, validateRouter);
|
||||
|
||||
58
backend/src/db/appIcons.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import {createHash} from 'crypto';
|
||||
import {PNG} from 'pngjs';
|
||||
import {inflateSync} from 'zlib';
|
||||
import db from './database';
|
||||
|
||||
export const MAX_ICON_BYTES = 256 * 1024;
|
||||
const signature = Buffer.from([137,80,78,71,13,10,26,10]);
|
||||
const iconPattern = /^\/app-icons\/([a-f0-9]{64})\.png$/;
|
||||
|
||||
export function saveAppIcon(input: Buffer): {iconPath: string; width: number; height: number} {
|
||||
if (input.length > MAX_ICON_BYTES || input.length < 33 || !input.subarray(0,8).equals(signature) ||
|
||||
input.readUInt32BE(8) !== 13 || input.toString('ascii',12,16) !== 'IHDR') {
|
||||
throw new Error('Choose a PNG image up to 256 KB.');
|
||||
}
|
||||
// Bound decoded allocation before passing untrusted bytes to the decoder.
|
||||
const width = input.readUInt32BE(16), height = input.readUInt32BE(20);
|
||||
if (width !== height || width < 16 || width > 512) throw new Error('Choose a square PNG between 16 and 512 pixels.');
|
||||
let image: PNG;
|
||||
try {
|
||||
// Validate chunk boundaries and a single header before pngjs reads metadata.
|
||||
// Its interlaced decoder inflates without a limit, so bound that same stream first.
|
||||
const compressed:Buffer[]=[];
|
||||
let ended=false;
|
||||
for(let offset=8;offset<input.length;){
|
||||
if(offset+12>input.length)throw new Error('Truncated PNG chunk');
|
||||
const size=input.readUInt32BE(offset),type=input.toString('ascii',offset+4,offset+8),next=offset+12+size;
|
||||
if(next>input.length||(type==='IHDR'&&offset!==8))throw new Error('Invalid PNG chunk');
|
||||
if(type==='IDAT')compressed.push(input.subarray(offset+8,offset+8+size));
|
||||
if(type==='IEND'){
|
||||
if(size!==0||next!==input.length)throw new Error('Invalid PNG ending');
|
||||
ended=true;
|
||||
}
|
||||
offset=next;
|
||||
}
|
||||
if(!ended||!compressed.length)throw new Error('Incomplete PNG');
|
||||
// A 512-square, 16-bit RGBA image including interlace filter bytes fits below 3 MiB.
|
||||
inflateSync(Buffer.concat(compressed),{maxOutputLength:3*1024*1024});
|
||||
image = PNG.sync.read(input, {checkCRC: true});
|
||||
}
|
||||
catch { throw new Error('This PNG could not be read. Export it again and try another file.'); }
|
||||
const canonical = PNG.sync.write(image, {colorType:6, bitDepth:8});
|
||||
const id = createHash('sha256').update(canonical).digest('hex');
|
||||
db.prepare('INSERT OR IGNORE INTO app_icons(id,png,width,height) VALUES(?,?,?,?)').run(id, canonical, width, height);
|
||||
return {iconPath:'/app-icons/'+id+'.png', width, height};
|
||||
}
|
||||
|
||||
export function getAppIcon(id: string): {png: Buffer} | undefined {
|
||||
if (!/^[a-f0-9]{64}$/.test(id)) return undefined;
|
||||
return db.prepare('SELECT png FROM app_icons WHERE id=?').get(id) as {png: Buffer} | undefined;
|
||||
}
|
||||
|
||||
export function publishedIconPath(value: unknown): string | null {
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string') throw new Error('Choose an uploaded application icon or use the Conductor default.');
|
||||
const match = value.match(iconPattern);
|
||||
if (!match || !getAppIcon(match[1])) throw new Error('That application icon is unavailable. Upload it again.');
|
||||
return value;
|
||||
}
|
||||
66
backend/src/db/includedApps.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import db from './database';
|
||||
import {isLocalIconPath} from '../lib/appIconPath';
|
||||
|
||||
export type IncludedApp = {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
launchPath: string;
|
||||
configurationPath?: string;
|
||||
iconPath?: string;
|
||||
audience: 'all-users' | 'admins';
|
||||
};
|
||||
|
||||
function localPath(value: unknown): value is string {
|
||||
if (typeof value !== 'string' || value.length > 512 || !value.startsWith('/') || value.startsWith('//')) return false;
|
||||
if (/[\s\\\u0000-\u001f\u007f]/.test(value) || /%(?:2f|5c|00|0a|0d)/i.test(value)) return false;
|
||||
try {
|
||||
const parsed = new URL(value, 'https://conductor.invalid');
|
||||
return parsed.origin === 'https://conductor.invalid' && !parsed.username && !parsed.password &&
|
||||
parsed.pathname !== '/' && parsed.pathname !== '/api' && !parsed.pathname.startsWith('/api/') &&
|
||||
parsed.pathname + parsed.search + parsed.hash === value;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export function validateIncludedApp(value: unknown): IncludedApp {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('An application manifest object is required.');
|
||||
const data = value as Record<string, unknown>;
|
||||
const fields = ['schemaVersion', 'id', 'name', 'description', 'version', 'launchPath', 'configurationPath', 'iconPath', 'audience'];
|
||||
if (Object.keys(data).some(key => !fields.includes(key)) || data.schemaVersion !== 1 ||
|
||||
typeof data.id !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(data.id) || data.id.length > 80 ||
|
||||
typeof data.name !== 'string' || !data.name.trim() || data.name.length > 120 ||
|
||||
typeof data.description !== 'string' || data.description.length > 1000 ||
|
||||
typeof data.version !== 'string' || !data.version.trim() || data.version.length > 80 ||
|
||||
!localPath(data.launchPath) || (data.configurationPath !== undefined && !localPath(data.configurationPath)) ||
|
||||
(data.iconPath !== undefined && !isLocalIconPath(data.iconPath)) ||
|
||||
(typeof data.audience !== 'string' || !['all-users', 'admins'].includes(data.audience))) {
|
||||
throw new Error('Invalid application manifest. Check its version, identity, audience and local application paths.');
|
||||
}
|
||||
return { ...data, name: data.name.trim(), version: data.version.trim() } as IncludedApp;
|
||||
}
|
||||
|
||||
export function registerIncludedApp(value: unknown): IncludedApp {
|
||||
const manifest = validateIncludedApp(value);
|
||||
db.prepare("INSERT INTO included_apps(id, manifest_json) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET manifest_json=excluded.manifest_json, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')")
|
||||
.run(manifest.id, JSON.stringify(manifest));
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function listIncludedApps(admin: boolean): IncludedApp[] {
|
||||
const rows = db.prepare('SELECT manifest_json FROM included_apps ORDER BY id').all() as Array<{manifest_json: string}>;
|
||||
return rows.map(row => validateIncludedApp(JSON.parse(row.manifest_json)))
|
||||
.filter(app => admin || app.audience === 'all-users')
|
||||
.map(app => {
|
||||
if (admin) return app;
|
||||
const { configurationPath: _configurationPath, ...publicApp } = app;
|
||||
return publicApp;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function unregisterIncludedApp(id: string): boolean {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) || id.length > 80) throw new Error('An application ID is required.');
|
||||
return db.prepare('DELETE FROM included_apps WHERE id=?').run(id).changes > 0;
|
||||
}
|
||||
@ -4,7 +4,7 @@ import db from './database';
|
||||
* Creates all required tables if they do not already exist.
|
||||
* Safe to call on every startup — uses IF NOT EXISTS throughout.
|
||||
*/
|
||||
export function initDatabase(): void {
|
||||
export function initDatabase(log = true): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@ -15,6 +15,23 @@ export function initDatabase(): void {
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS installed_packages (app_id TEXT PRIMARY KEY, package_json TEXT NOT NULL, digest TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS app_connections (app_id TEXT PRIMARY KEY, encrypted_value TEXT NOT NULL, iv TEXT NOT NULL, auth_tag TEXT NOT NULL);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_icons (
|
||||
id TEXT PRIMARY KEY,
|
||||
png BLOB NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS included_apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
manifest_json TEXT NOT NULL,
|
||||
registered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secrets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@ -91,9 +108,12 @@ export function initDatabase(): void {
|
||||
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');
|
||||
|
||||
const publicationColumns = new Set((db.pragma('table_info(published_apps)') as Array<{name:string}>).map(column => column.name));
|
||||
if (!publicationColumns.has('icon_path')) db.exec('ALTER TABLE published_apps ADD COLUMN icon_path TEXT');
|
||||
|
||||
const userColumns = new Set((db.pragma('table_info(users)') as Array<{name:string}>).map((column) => column.name));
|
||||
if (!userColumns.has('display_name')) db.exec('ALTER TABLE users ADD COLUMN display_name TEXT');
|
||||
if (!userColumns.has('email')) db.exec('ALTER TABLE users ADD COLUMN email TEXT');
|
||||
|
||||
console.log('Database initialised');
|
||||
if (log) console.log('Database initialised');
|
||||
}
|
||||
|
||||
89
backend/src/db/installedApps.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {createHash,randomUUID} from 'crypto';
|
||||
import db,{DATA_DIR} from './database';
|
||||
import {validatePackage,type AppPackage} from '../lib/appPackage';
|
||||
import {encryptSecret,decryptSecret} from '../lib/secretCrypto';
|
||||
import {registerIncludedApp,unregisterIncludedApp} from './includedApps';
|
||||
|
||||
export const APP_ROOT=path.resolve(DATA_DIR,'applications');
|
||||
type Row={app_id:string;package_json:string;digest:string};
|
||||
export type Installed={manifest:AppPackage;digest:string;directory:string};
|
||||
export type Connection={origin:string;token?:string;values:Record<string,string>};
|
||||
export function allInstalled():Installed[]{return (db.prepare('SELECT * FROM installed_packages ORDER BY app_id').all() as Row[]).map(row=>({manifest:JSON.parse(row.package_json),digest:row.digest,directory:path.join(APP_ROOT,row.app_id,row.digest)}));}
|
||||
export function findInstalled(id:string):Installed|undefined{return allInstalled().find(app=>app.manifest.application.id===id);}
|
||||
export function matchInstalled(urlPath:string):Installed|undefined{return allInstalled().find(app=>urlPath===app.manifest.application.launchPath||urlPath.startsWith(app.manifest.application.launchPath+'/'));}
|
||||
export function connectionFor(id:string):Connection|undefined{
|
||||
const row=db.prepare('SELECT * FROM app_connections WHERE app_id=?').get(id) as {encrypted_value:string;iv:string;auth_tag:string}|undefined;
|
||||
return row?decryptSecret<Connection>(row.encrypted_value,row.iv,row.auth_tag):undefined;
|
||||
}
|
||||
function validateConnection(value:unknown):Connection{
|
||||
if(!value||typeof value!=='object'||Array.isArray(value))throw new Error('A connection object is required.');
|
||||
const c=value as Connection;
|
||||
if(Object.keys(c).some(k=>!['origin','token','values'].includes(k)))throw new Error('Unknown connection field.');
|
||||
const url=new URL(c.origin);
|
||||
if(!['http:','https:'].includes(url.protocol)||url.username||url.password||url.search||url.hash||url.pathname.includes('%')||!/^\/[A-Za-z0-9_/-]*$/.test(url.pathname))throw new Error('Use a fixed HTTP(S) backend address without credentials or queries.');
|
||||
if(c.token!==undefined&&(typeof c.token!=='string'||c.token.length<16||c.token.length>4096||/[\s]/.test(c.token)))throw new Error('Invalid internal connection token.');
|
||||
if(!c.values||typeof c.values!=='object'||Array.isArray(c.values)||Object.keys(c.values).length>20)throw new Error('Connection values are required.');
|
||||
for(const [k,v] of Object.entries(c.values))if(!/^[A-Za-z][A-Za-z0-9]*$/.test(k)||typeof v!=='string'||! /^[A-Za-z0-9_-]{1,120}$/.test(v))throw new Error('Invalid connection value.');
|
||||
return {origin:url.href.replace(/\/$/,''),token:c.token,values:c.values};
|
||||
}
|
||||
function writeConnection(id:string,c:Connection){
|
||||
const e=encryptSecret(c);
|
||||
db.prepare('INSERT INTO app_connections(app_id,encrypted_value,iv,auth_tag) VALUES(?,?,?,?) ON CONFLICT(app_id) DO UPDATE SET encrypted_value=excluded.encrypted_value,iv=excluded.iv,auth_tag=excluded.auth_tag').run(id,e.encryptedValue,e.iv,e.authTag);
|
||||
}
|
||||
export function installPackage(directory:string,connection?:unknown){
|
||||
const source=fs.realpathSync(directory),manifestFile=path.join(source,'conductor-app.json');
|
||||
if(fs.lstatSync(manifestFile).isSymbolicLink()||fs.statSync(manifestFile).size>65536)throw new Error('Invalid package manifest file.');
|
||||
const manifest=validatePackage(JSON.parse(fs.readFileSync(manifestFile,'utf8')));
|
||||
const id=manifest.application.id;
|
||||
const existing=allInstalled();
|
||||
for(const other of existing)if(other.manifest.application.id!==id&&other.manifest.application.launchPath===manifest.application.launchPath)throw new Error('Another application owns this launch path.');
|
||||
const legacy=db.prepare('SELECT id,manifest_json FROM included_apps WHERE id<>?').all(id) as {id:string;manifest_json:string}[];
|
||||
if(legacy.some(row=>JSON.parse(row.manifest_json).launchPath.split('?')[0]===manifest.application.launchPath))throw new Error('An existing catalog entry owns this launch path.');
|
||||
let c=connection===undefined?connectionFor(id):validateConnection(connection);
|
||||
if(manifest.api&&!c)throw new Error('Provide the backend connection on standard input before installing this package.');
|
||||
if(c&&manifest.api){
|
||||
const required=new Set<string>();
|
||||
for(const r of manifest.api.routes)for(const item of r.path.matchAll(/\{([A-Za-z][A-Za-z0-9]*)\}/g))required.add(item[1]);
|
||||
for(const source of Object.values(manifest.api.headers||{}))if(source.startsWith('config:'))required.add(source.slice(7));
|
||||
if([...required].some(key=>!c!.values[key]))throw new Error('A required backend connection value is missing.');
|
||||
if(manifest.api.routes.some(r=>r.access!=='bearer')&&!c.token)throw new Error('An internal token is required for signed-in or public bridge routes.');
|
||||
}
|
||||
// Verify every byte and reject symbolic links before installing any content.
|
||||
let total=0;const bytes=new Map<string,Buffer>();
|
||||
for(const [file,expected] of Object.entries(manifest.files)){
|
||||
const filename=path.join(source,file);
|
||||
for(let current=filename;current!==source;current=path.dirname(current))if(fs.lstatSync(current).isSymbolicLink())throw new Error('Package symlinks are not allowed.');
|
||||
const stat=fs.statSync(filename);if(!stat.isFile()||stat.size>10*1024*1024||(total+=stat.size)>20*1024*1024)throw new Error('Package exceeds the 20 MiB asset limit.');
|
||||
const buffer=fs.readFileSync(filename);if(createHash('sha256').update(buffer).digest('hex')!==expected)throw new Error('Package asset checksum mismatch: '+file);
|
||||
bytes.set(file,buffer);
|
||||
}
|
||||
const json=JSON.stringify(manifest),digest=createHash('sha256').update(json).digest('hex');
|
||||
const parent=path.join(APP_ROOT,id);fs.mkdirSync(parent,{recursive:true,mode:0o700});
|
||||
const destination=path.join(parent,digest),stage=path.join(parent,'.install-'+randomUUID());
|
||||
fs.mkdirSync(stage,{mode:0o700});
|
||||
try{
|
||||
for(const [name,buffer] of bytes){const target=path.join(stage,name);fs.mkdirSync(path.dirname(target),{recursive:true,mode:0o700});fs.writeFileSync(target,buffer,{mode:0o600});}
|
||||
fs.writeFileSync(path.join(stage,'conductor-app.json'),json,{mode:0o600});
|
||||
if(fs.existsSync(destination)){
|
||||
for(const [name,buffer] of bytes)if(!fs.readFileSync(path.join(destination,name)).equals(buffer))throw new Error('An installed package asset was modified. Restore its verified package before proceeding.');
|
||||
fs.rmSync(stage,{recursive:true});
|
||||
}else fs.renameSync(stage,destination);
|
||||
db.transaction(()=>{
|
||||
if(c)writeConnection(id,c);
|
||||
const iconPath=manifest.entry.icon?'/installed-app-assets/'+id+'/'+digest+'/'+manifest.entry.icon:undefined;
|
||||
registerIncludedApp({...manifest.application,...(iconPath?{iconPath}:{})});
|
||||
db.prepare('INSERT INTO installed_packages(app_id,package_json,digest) VALUES(?,?,?) ON CONFLICT(app_id) DO UPDATE SET package_json=excluded.package_json,digest=excluded.digest').run(id,json,digest);
|
||||
})();
|
||||
}finally{if(fs.existsSync(stage))fs.rmSync(stage,{recursive:true});}
|
||||
return {installed:id,version:manifest.application.version,digest};
|
||||
}
|
||||
export function removePackage(id:string){
|
||||
if(!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))throw new Error('Invalid application ID.');
|
||||
return db.transaction(()=>{db.prepare('DELETE FROM installed_packages WHERE app_id=?').run(id);db.prepare('DELETE FROM app_connections WHERE app_id=?').run(id);return{id,removed:unregisterIncludedApp(id)};})();
|
||||
}
|
||||
export function packageSummary(app:Installed){
|
||||
const base='/installed-app-assets/'+app.manifest.application.id+'/'+app.digest+'/';
|
||||
return {...app.manifest.application,iconPath:app.manifest.entry.icon?base+app.manifest.entry.icon:undefined,hostApiVersion:1,script:base+app.manifest.entry.script,style:app.manifest.entry.style?base+app.manifest.entry.style:undefined,apiBasePath:app.manifest.api?.basePath};
|
||||
}
|
||||
@ -1,16 +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 type PublishedAppRow={id:string;slug:string;source_project_id:number;display_name:string;description:string;visibility:PublishedVisibility;icon_path:string|null;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;iconPath:string|null;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,iconPath:r.icon_path,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 createPublished(input:{slug:string;sourceProjectId:number;displayName:string;description:string;visibility:PublishedVisibility;snapshotJson:string;schemaVersion:string;publishedBy:string;iconPath?:string|null}):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,icon_path) VALUES (?,?,?,?,?,?,?,?,?,?)').run(id,input.slug,input.sourceProjectId,input.displayName,input.description,input.visibility,input.snapshotJson,input.schemaVersion,input.publishedBy,input.iconPath??null);return admin(getPublishedRowById(id)!);}
|
||||
export function updatePublishedMetadata(id:string,input:{slug:string;displayName:string;description:string;visibility:PublishedVisibility;iconPath:string|null}):PublishedAppAdmin|undefined{db.prepare("UPDATE published_apps SET slug=?,display_name=?,description=?,visibility=?,icon_path=?,updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?").run(input.slug,input.displayName,input.description,input.visibility,input.iconPath,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;}
|
||||
|
||||
9
backend/src/lib/appIconPath.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/** Installer-supplied icon URLs must resolve to local image assets, never remote services or API routes. */
|
||||
export function isLocalIconPath(value: unknown): value is string {
|
||||
if (typeof value !== 'string' || value.length > 512 || !/^\/[A-Za-z0-9_./-]+\.(?:svg|png|ico|webp|jpe?g)$/i.test(value)) return false;
|
||||
if (value.startsWith('//') || value.startsWith('/api/')) return false;
|
||||
try {
|
||||
const parsed = new URL(value, 'https://conductor.invalid');
|
||||
return parsed.origin === 'https://conductor.invalid' && parsed.pathname === value;
|
||||
} catch { return false; }
|
||||
}
|
||||
44
backend/src/lib/appPackage.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {validateIncludedApp, type IncludedApp} from '../db/includedApps';
|
||||
export type AppRoute={path:string;methods:string[];access:'user'|'admin'|'public'|'bearer';query?:string[];response?:'json'|'image';redirect?:boolean};
|
||||
export type AppPackage={packageVersion:1;hostApiVersion:1;application:IncludedApp;entry:{script:string;style?:string;icon?:string};files:Record<string,string>;api?:{basePath:string;headers?:Record<string,string>;routes:AppRoute[]}};
|
||||
const id=/^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const reserved=new Set(['api','apps','installed-app-assets','app-icons','static','favicon.svg','favicon.ico','apple-touch-icon.png']);
|
||||
function keys(value:Record<string,unknown>,allowed:string[]){if(Object.keys(value).some(key=>!allowed.includes(key)))throw new Error('Unknown package field.');}
|
||||
export function assetPath(value:unknown):value is string{return typeof value==='string'&&value.length<=200&&/^[A-Za-z0-9_-]+(?:[/.][A-Za-z0-9_-]+)*\.(?:js|css|svg|png|ico|webp|jpe?g|woff2?)$/.test(value)&&!value.split('/').some(part=>part==='.'||part==='..');}
|
||||
export function validatePackage(value:unknown):AppPackage{
|
||||
if(!value||typeof value!=='object'||Array.isArray(value))throw new Error('An application package manifest is required.');
|
||||
const p=value as AppPackage;keys(p as unknown as Record<string,unknown>,['packageVersion','hostApiVersion','application','entry','files','api']);
|
||||
if(p.packageVersion!==1||p.hostApiVersion!==1)throw new Error('This package needs an unsupported Conductor host API.');
|
||||
p.application=validateIncludedApp(p.application);
|
||||
if(p.application.iconPath!==undefined)throw new Error('Use entry.icon for a packaged application.');
|
||||
const base=p.application.launchPath;
|
||||
if(!/^\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(base)||reserved.has(base.slice(1)))throw new Error('A package needs one non-reserved top-level launch path.');
|
||||
if(p.application.configurationPath&&!p.application.configurationPath.startsWith(base+'?')&&p.application.configurationPath!==base&&!p.application.configurationPath.startsWith(base+'/'))throw new Error('Configuration must remain inside the application route.');
|
||||
if(!p.entry||!p.files||typeof p.files!=='object'||Array.isArray(p.files))throw new Error('Package entry and file checksums are required.');
|
||||
keys(p.entry as Record<string,unknown>,['script','style','icon']);
|
||||
const names=Object.keys(p.files);
|
||||
if(names.length<1||names.length>200)throw new Error('Packages must contain 1–200 assets.');
|
||||
for(const name of names)if(!assetPath(name)||! /^[a-f0-9]{64}$/.test(p.files[name]))throw new Error('Invalid package asset or checksum.');
|
||||
if(!p.entry.script?.endsWith('.js')||!p.files[p.entry.script]||(p.entry.style&&(!p.entry.style.endsWith('.css')||!p.files[p.entry.style]))||(p.entry.icon&&(!/\.(svg|png|ico|webp|jpe?g)$/.test(p.entry.icon)||!p.files[p.entry.icon])))throw new Error('Entry assets are missing or have the wrong type.');
|
||||
if(p.api){
|
||||
keys(p.api as unknown as Record<string,unknown>,['basePath','headers','routes']);
|
||||
if(p.api.basePath!=='/api/'+p.application.id||!id.test(p.application.id)||['auth','health','admin','projects','secrets','executions','proxy','published-apps','included-apps','app-icons','installed-apps'].includes(p.application.id))throw new Error('API base must be the application ID in its own API namespace.');
|
||||
if(!Array.isArray(p.api.routes)||!p.api.routes.length||p.api.routes.length>100)throw new Error('API routes are required.');
|
||||
const seen=new Set<string>();
|
||||
for(const r of p.api.routes){
|
||||
keys(r as unknown as Record<string,unknown>,['path','methods','access','query','response','redirect']);
|
||||
if(!/^\/(?:[A-Za-z0-9_.-]+|:[A-Za-z][A-Za-z0-9]*|\{[A-Za-z][A-Za-z0-9]*\})(?:\/(?:[A-Za-z0-9_.-]+|:[A-Za-z][A-Za-z0-9]*|\{[A-Za-z][A-Za-z0-9]*\}))*$/.test(r.path)||r.path.split('/').some(x=>x==='.'||x==='..'))throw new Error('Invalid API route.');
|
||||
if(!Array.isArray(r.methods)||!r.methods.length||r.methods.some(m=>!['GET','POST','PUT','PATCH','DELETE'].includes(m))||!['user','admin','public','bearer'].includes(r.access))throw new Error('Invalid API route access.');
|
||||
if(r.access==='public'&&r.methods.some(m=>m!=='GET'))throw new Error('Public routes are read-only.');
|
||||
if(r.response&&!['json','image'].includes(r.response))throw new Error('Unsupported API response format.');
|
||||
if(r.redirect!==undefined&&(r.redirect!==true||r.methods.some(m=>m!=='GET')||r.access!=='user'))throw new Error('Callbacks must be signed-in GET routes.');
|
||||
if(r.query&&(!Array.isArray(r.query)||r.query.length>10||r.query.some(q=>!/^[a-z][a-z0-9_]*$/.test(q))))throw new Error('Invalid allowed query parameters.');
|
||||
for(const m of r.methods){const key=m+' '+r.path;if(seen.has(key))throw new Error('Duplicate API route.');seen.add(key);}
|
||||
}
|
||||
if(p.api.headers){
|
||||
if(Object.keys(p.api.headers).length>10)throw new Error('Too many identity headers.');
|
||||
for(const [header,source] of Object.entries(p.api.headers))if(!/^X-[A-Za-z0-9-]{1,60}$/.test(header)||!(/^(principal:(userId|role)|config:[A-Za-z][A-Za-z0-9]*)$/.test(source)))throw new Error('Invalid identity header binding.');
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
@ -1,13 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import {publishedIconPath} from '../db/appIcons';
|
||||
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};}
|
||||
function validateSnapshot(json:string,nextVisibility:PublishedVisibility):SnapshotResult{let document:unknown;try{document=JSON.parse(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};}
|
||||
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.'}};return validateSnapshot(project.project_json,nextVisibility);}
|
||||
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('/',(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;}let iconPath:string|null=null;try{if(req.body?.iconPath!==undefined)iconPath=publishedIconPath(req.body.iconPath);}catch(error){res.status(400).json({code:'ICON_INVALID',error:(error as Error).message});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,iconPath}));}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;}let iconPath=row.icon_path;try{if(req.body?.iconPath!==undefined)iconPath=publishedIconPath(req.body.iconPath);}catch(error){res.status(400).json({code:'ICON_INVALID',error:(error as Error).message});return;}if(nextVisibility==='public'){const checked=validateSnapshot(row.snapshot_json,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,iconPath}));}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,8 @@
|
||||
import test from 'node:test';
|
||||
import {PNG} from 'pngjs';
|
||||
import {deflateSync} from 'zlib';
|
||||
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 { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
@ -147,6 +149,67 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his
|
||||
const setupThrottled=await fetch(`${backendUrl}/api/auth/setup`,{method:'POST',headers:{cookie:setupCookie,'x-setup-token':setupStatus.body.setupToken,'content-type':'application/json'},body:'{}'});
|
||||
assert.equal(setupThrottled.status,429);
|
||||
|
||||
|
||||
// Icon uploads are bounded, decoded PNGs; content-addressed assets survive restart.
|
||||
const iconImage=new PNG({width:32,height:32});iconImage.data.fill(180);
|
||||
const iconBuffer=PNG.sync.write(iconImage);
|
||||
assert.equal((await fetch(backendUrl+'/api/app-icons',{method:'POST',headers:{'content-type':'image/png'},body:iconBuffer})).status,401);
|
||||
assert.equal((await fetch(backendUrl+'/api/app-icons',{method:'POST',headers:{cookie:authCookie,'content-type':'image/png'},body:iconBuffer})).status,403);
|
||||
const iconUpload=await jsonRequest(backendUrl,'/api/app-icons',{method:'POST',headers:{'content-type':'image/png'},body:iconBuffer});
|
||||
assert.equal(iconUpload.response.status,201);
|
||||
const duplicateHeader=Buffer.concat([iconBuffer.subarray(0,33),iconBuffer.subarray(8)]);
|
||||
assert.equal((await fetch(backendUrl+'/api/app-icons',{method:'POST',headers:{cookie:authCookie,'x-csrf-token':csrfToken,'content-type':'image/png'},body:duplicateHeader})).status,400);
|
||||
function pngChunk(type:string,bytes:Buffer):Buffer{
|
||||
const chunk=Buffer.alloc(bytes.length+12);chunk.writeUInt32BE(bytes.length);chunk.write(type,4);bytes.copy(chunk,8);
|
||||
let crc=0xffffffff;for(const byte of chunk.subarray(4,-4)){crc^=byte;for(let bit=0;bit<8;bit++)crc=(crc>>>1)^((crc&1)?0xedb88320:0);}
|
||||
chunk.writeUInt32BE((crc^0xffffffff)>>>0,chunk.length-4);return chunk;
|
||||
}
|
||||
const interlacedHeader=Buffer.from(iconBuffer.subarray(16,29));interlacedHeader[12]=1;
|
||||
const excessiveInflate=Buffer.concat([iconBuffer.subarray(0,8),pngChunk('IHDR',interlacedHeader),pngChunk('IDAT',deflateSync(Buffer.alloc(4*1024*1024))),pngChunk('IEND',Buffer.alloc(0))]);
|
||||
assert.equal((await fetch(backendUrl+'/api/app-icons',{method:'POST',headers:{cookie:authCookie,'x-csrf-token':csrfToken,'content-type':'image/png'},body:excessiveInflate})).status,400);
|
||||
const uploadedIconPath=iconUpload.body.iconPath;
|
||||
assert.match(uploadedIconPath,/^\/app-icons\/[a-f0-9]{64}\.png$/);
|
||||
const sameIcon=await jsonRequest(backendUrl,'/api/app-icons',{method:'POST',headers:{'content-type':'image/png'},body:iconBuffer});
|
||||
assert.equal(sameIcon.body.iconPath,uploadedIconPath);
|
||||
const readIcon=await fetch(backendUrl+uploadedIconPath);
|
||||
assert.equal(readIcon.status,200);assert.match(readIcon.headers.get('content-type')??'',/image\/png/);
|
||||
assert.match(readIcon.headers.get('cache-control')??'',/immutable/);
|
||||
assert.equal(PNG.sync.read(Buffer.from(await readIcon.arrayBuffer())).width,32);
|
||||
assert.equal((await jsonRequest(backendUrl,'/api/app-icons',{method:'POST',headers:{'content-type':'image/svg+xml'},body:'<svg></svg>'})).response.status,415);
|
||||
assert.equal((await jsonRequest(backendUrl,'/api/app-icons',{method:'POST',headers:{'content-type':'image/png'},body:Buffer.alloc(256*1024+1)})).response.status,413);
|
||||
const giantIcon=Buffer.from(iconBuffer);giantIcon.writeUInt32BE(100000,16);
|
||||
for(const invalid of [giantIcon,Buffer.from('<svg onload="bad()"/>'),PNG.sync.write(new PNG({width:32,height:16}))]){
|
||||
assert.equal((await jsonRequest(backendUrl,'/api/app-icons',{method:'POST',headers:{'content-type':'image/png'},body:invalid})).response.status,400);
|
||||
}
|
||||
assert.equal((await fetch(backendUrl+'/app-icons/missing.png')).status,404);
|
||||
|
||||
// Installed applications are registered by VM installers, never seeded or writable through a browser.
|
||||
const registryCli = (command: string, value?: unknown) => spawnSync(process.execPath,
|
||||
[path.resolve(__dirname, '../scripts/includedApps.js'), command],
|
||||
{env:{...process.env,CONDUCTOR_DATA_DIR:dataDir},input:value===undefined?undefined:JSON.stringify(value),encoding:'utf8'});
|
||||
assert.equal((await fetch(backendUrl+'/api/included-apps')).status,401);
|
||||
assert.deepEqual((await jsonRequest(backendUrl,'/api/included-apps')).body,[]);
|
||||
const included = {schemaVersion:1,id:'example-tool',name:'Example Tool',description:'Installer-managed application',version:'1.0.0',launchPath:'/example-tool',configurationPath:'/example-tool?view=settings',audience:'all-users'};
|
||||
assert.equal(registryCli('register',included).status,0);
|
||||
assert.equal(registryCli('register',{...included,version:'1.1.0'}).status,0);
|
||||
assert.equal(registryCli('register',{...included,id:'admin-tool',name:'Admin Tool',audience:'admins'}).status,0);
|
||||
for (const launchPath of ['https://evil.example/app','//evil.example/app','/../other','/api/admin/users','/example%2fredirect','/example tool']) {
|
||||
assert.notEqual(registryCli('register',{...included,launchPath}).status,0);
|
||||
}
|
||||
assert.notEqual(registryCli('register',{...included,secret:'must-not-store'}).status,0);
|
||||
for (const audience of [['admins'],null,true,'everyone']) assert.notEqual(registryCli('register',{...included,audience}).status,0);
|
||||
const installed=(await jsonRequest(backendUrl,'/api/included-apps')).body;
|
||||
assert.equal(installed.length,2);
|
||||
assert.equal(installed.find((app:any)=>app.id==='example-tool').version,'1.1.0');
|
||||
assert.equal(installed.find((app:any)=>app.id==='example-tool').configurationPath,'/example-tool?view=settings');
|
||||
assert.equal((await jsonRequest(backendUrl,'/api/included-apps',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(included)})).response.status,404);
|
||||
|
||||
for (const iconPath of ['https://remote.example/icon.png','//remote.example/icon.png','/api/secret.png','/../favicon.svg','data:image/svg+xml,bad','/favicon.svg?token=bad']) {
|
||||
assert.notEqual(registryCli('register',{...included,iconPath}).status,0);
|
||||
}
|
||||
assert.equal(registryCli('register',{...included,version:'1.1.0',iconPath:'/favicon.svg'}).status,0);
|
||||
assert.equal((await jsonRequest(backendUrl,'/api/included-apps')).body.find((app:any)=>app.id==='example-tool').iconPath,'/favicon.svg');
|
||||
|
||||
const validDoc = projectDocument();
|
||||
const validation = await jsonRequest(backendUrl, '/api/projects/validate', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(validDoc),
|
||||
@ -195,6 +258,8 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his
|
||||
}
|
||||
child = spawnBackend();
|
||||
await waitForBackend(child, backendUrl);
|
||||
assert.equal((await jsonRequest(backendUrl,'/api/included-apps')).body.length,2);
|
||||
assert.equal((await fetch(backendUrl+uploadedIconPath)).status,200);
|
||||
const afterRestart = await jsonRequest(backendUrl, `/api/projects/${projectId}`);
|
||||
assert.equal(afterRestart.response.status, 200);
|
||||
assert.deepEqual(JSON.parse(afterRestart.body.project_json), updatedDoc);
|
||||
@ -211,6 +276,32 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his
|
||||
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 userAppsResponse=await fetch(backendUrl+'/api/included-apps',{headers:{cookie:userCookie}});
|
||||
assert.equal(userAppsResponse.status,200);
|
||||
const userApps=await userAppsResponse.json() as any[];
|
||||
assert.equal(userApps.length,1);assert.equal(userApps[0].id,'example-tool');
|
||||
assert.equal(userApps[0].iconPath,'/favicon.svg');
|
||||
assert.equal((await fetch(backendUrl+'/api/app-icons',{method:'POST',headers:{cookie:userCookie,'x-csrf-token':userLoginBody.csrfToken,'content-type':'image/png'},body:iconBuffer})).status,403);
|
||||
assert.equal('configurationPath' in userApps[0],false);
|
||||
const removed=spawnSync(process.execPath,[path.resolve(__dirname,'../scripts/includedApps.js'),'unregister','example-tool'],{env:{...process.env,CONDUCTOR_DATA_DIR:dataDir},encoding:'utf8'});
|
||||
assert.equal(removed.status,0);
|
||||
assert.deepEqual(await (await fetch(backendUrl+'/api/included-apps',{headers:{cookie:userCookie}})).json(),[]);
|
||||
|
||||
// Renaming through the list updates the canonical document without losing its contents.
|
||||
const metadataCreated=await jsonRequest(backendUrl,'/api/projects',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Metadata project'})});
|
||||
assert.equal(metadataCreated.response.status,201);
|
||||
const metadataId=metadataCreated.body.id;
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
const metadataPatch={name:'Renamed project',description:'Updated description',expectedUpdatedAt:metadataCreated.body.updated_at};
|
||||
const renamed=await jsonRequest(backendUrl,'/api/projects/'+metadataId,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify(metadataPatch)});
|
||||
assert.equal(renamed.response.status,200);
|
||||
const renamedDocument=JSON.parse(renamed.body.project_json);
|
||||
assert.equal(renamedDocument.project.name,'Renamed project');
|
||||
assert.equal(renamedDocument.project.description,'Updated description');
|
||||
assert.deepEqual(renamedDocument.project.pages,JSON.parse(metadataCreated.body.project_json).project.pages);
|
||||
assert.equal((await jsonRequest(backendUrl,'/api/projects/'+metadataId,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify(metadataPatch)})).response.status,409);
|
||||
assert.equal((await jsonRequest(backendUrl,'/api/projects/'+metadataId,{method:'DELETE',headers:{'x-project-updated-at':metadataCreated.body.updated_at}})).response.status,409);
|
||||
assert.equal((await jsonRequest(backendUrl,'/api/projects/'+metadataId,{method:'DELETE',headers:{'x-project-updated-at':renamed.body.updated_at}})).response.status,204);
|
||||
const wrongCurrent=await fetch(`${backendUrl}/api/auth/change-password`,{method:'POST',headers:{cookie:userCookie,'x-csrf-token':userLoginBody.csrfToken,'content-type':'application/json'},body:JSON.stringify({currentPassword:'wrong-password',newPassword:'changed-user-password'})});
|
||||
assert.equal(wrongCurrent.status,401);
|
||||
const changedPassword=await fetch(`${backendUrl}/api/auth/change-password`,{method:'POST',headers:{cookie:userCookie,'x-csrf-token':userLoginBody.csrfToken,'content-type':'application/json'},body:JSON.stringify({currentPassword:'integration-user-password',newPassword:'changed-user-password'})});
|
||||
@ -218,14 +309,24 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his
|
||||
assert.equal((await changedPassword.json() as any).changed,true);
|
||||
assert.equal((await fetch(`${backendUrl}/api/auth/login`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({username:'integration-user',password:'integration-user-password'})})).status,401);
|
||||
assert.equal((await fetch(`${backendUrl}/api/auth/login`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({username:'integration-user',password:'changed-user-password'})})).status,200);
|
||||
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' }) });
|
||||
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', iconPath:uploadedIconPath }) });
|
||||
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.iconPath,uploadedIconPath);
|
||||
assert.equal(publicRuntime.body.document.project.name, 'Updated Project');
|
||||
const publicationUrl='/api/admin/published-apps/'+publication.body.id;
|
||||
const iconReset=await jsonRequest(backendUrl,publicationUrl,{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({iconPath:null})});
|
||||
assert.equal(iconReset.response.status,200);assert.equal(iconReset.body.iconPath,null);
|
||||
assert.equal((await jsonRequest(backendUrl,publicationUrl,{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({iconPath:'https://example.test/icon.png'})})).response.status,400);
|
||||
const iconSelected=await jsonRequest(backendUrl,publicationUrl,{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({iconPath:uploadedIconPath})});
|
||||
assert.equal(iconSelected.body.iconPath,uploadedIconPath);
|
||||
const republishedIcon=await jsonRequest(backendUrl,publicationUrl+'/publish',{method:'POST'});
|
||||
assert.equal(republishedIcon.body.iconPath,uploadedIconPath);
|
||||
assert.equal((await (await fetch(backendUrl+'/api/published-apps/integration-app')).json() as any).iconPath,uploadedIconPath);
|
||||
const unpublished = await jsonRequest(backendUrl, `/api/admin/published-apps/${publication.body.id}/unpublish`, { method: 'POST' });
|
||||
assert.equal(unpublished.response.status, 200);
|
||||
|
||||
@ -246,6 +347,28 @@ test('full API integration: health, validation, CRUD round trip, proxy auth, his
|
||||
secretIds.push(secret.body.id);
|
||||
}
|
||||
|
||||
// Icon-only updates validate the published snapshot, not an unrelated edited draft.
|
||||
const safeIconDoc=projectDocument('Icon Snapshot');
|
||||
const snapshotProject=await jsonRequest(backendUrl,'/api/projects',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Icon Snapshot',project_json:JSON.stringify(safeIconDoc)})});
|
||||
assert.equal(snapshotProject.response.status,201);
|
||||
const snapshotProjectId=snapshotProject.body.id;
|
||||
const publicIconApp=await jsonRequest(backendUrl,'/api/admin/published-apps',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({sourceProjectId:snapshotProjectId,slug:'icon-snapshot-public',displayName:'Icon Snapshot',visibility:'public'})});
|
||||
assert.equal(publicIconApp.response.status,201);
|
||||
const credentialIconDoc=structuredClone(safeIconDoc) as any;
|
||||
credentialIconDoc.project.actions=[{id:'private-action',name:'Private action',method:'GET',url:mockOrigin+'/echo',authenticationType:'bearerToken',secretReferenceId:secretIds[1]}];
|
||||
const updateSnapshotDraft=async(document:Json)=>jsonRequest(backendUrl,'/api/projects/'+snapshotProjectId,{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Icon Snapshot',project_json:JSON.stringify(document)})});
|
||||
assert.equal((await updateSnapshotDraft(credentialIconDoc)).response.status,200);
|
||||
const updatePublicIcon=await jsonRequest(backendUrl,'/api/admin/published-apps/'+publicIconApp.body.id,{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({iconPath:uploadedIconPath})});
|
||||
assert.equal(updatePublicIcon.response.status,200,'An unpublished credential action must not prevent changing a safe published icon.');
|
||||
assert.equal((await jsonRequest(backendUrl,'/api/admin/published-apps/'+publicIconApp.body.id+'/publish',{method:'POST'})).body.code,'PUBLIC_CREDENTIAL_ACTION_FORBIDDEN');
|
||||
const privateIconApp=await jsonRequest(backendUrl,'/api/admin/published-apps',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({sourceProjectId:snapshotProjectId,slug:'icon-snapshot-private',displayName:'Private Snapshot',visibility:'authenticated'})});
|
||||
assert.equal(privateIconApp.response.status,201);
|
||||
assert.equal((await updateSnapshotDraft(safeIconDoc)).response.status,200);
|
||||
const unsafeVisibility=await jsonRequest(backendUrl,'/api/admin/published-apps/'+privateIconApp.body.id,{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({visibility:'public',iconPath:uploadedIconPath})});
|
||||
assert.equal(unsafeVisibility.response.status,409);
|
||||
assert.equal(unsafeVisibility.body.code,'PUBLIC_CREDENTIAL_ACTION_FORBIDDEN');
|
||||
assert.equal((await fetch(backendUrl+'/api/published-apps/icon-snapshot-private')).status,401,'Editing a draft must not expose an older credential-bearing published snapshot.');
|
||||
|
||||
const modes = ['anonymous', ...secretInputs.map(([type]) => type)] as const;
|
||||
for (let index = 0; index < modes.length; index += 1) {
|
||||
const authenticationType = modes[index];
|
||||
|
||||
24
backend/src/routes/appIcons.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import {Router, raw} from 'express';
|
||||
import {getAppIcon, MAX_ICON_BYTES, saveAppIcon} from '../db/appIcons';
|
||||
|
||||
export const uploadAppIconsRouter = Router();
|
||||
uploadAppIconsRouter.post('/', raw({type:'image/png',limit:MAX_ICON_BYTES}), (req,res) => {
|
||||
if (!req.is('image/png') || !Buffer.isBuffer(req.body)) {res.status(415).json({error:'Choose a PNG image.'});return;}
|
||||
try {res.status(201).json(saveAppIcon(req.body));}
|
||||
catch(error) {res.status(400).json({error:error instanceof Error?error.message:'The icon could not be saved.'});}
|
||||
});
|
||||
uploadAppIconsRouter.use((error: Error & {type?:string}, _req: import('express').Request, res: import('express').Response, next: import('express').NextFunction) => {
|
||||
if (error.type==='entity.too.large') {res.status(413).json({error:'Choose a PNG image up to 256 KB.'});return;}
|
||||
next(error);
|
||||
});
|
||||
|
||||
// Icons are public decorative assets, including for public published applications.
|
||||
export const appIconsRouter = Router();
|
||||
appIconsRouter.get('/:filename', (req,res) => {
|
||||
const match = req.params.filename.match(/^([a-f0-9]{64})\.png$/);
|
||||
const icon = match ? getAppIcon(match[1]) : undefined;
|
||||
if (!icon) {res.status(404).end();return;}
|
||||
res.set('Cache-Control','public, max-age=31536000, immutable');
|
||||
res.set('X-Content-Type-Options','nosniff');
|
||||
res.type('png').send(icon.png);
|
||||
});
|
||||
@ -6,6 +6,7 @@ router.get('/', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
service: 'conductor-backend',
|
||||
status: 'ok',
|
||||
capabilities: {installedApplications: 1},
|
||||
version: process.env.CONDUCTOR_VERSION ?? 'development',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
10
backend/src/routes/includedApps.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { listIncludedApps } from '../db/includedApps';
|
||||
|
||||
const router = Router();
|
||||
router.get('/', (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json(listIncludedApps(req.principal?.role === 'admin'));
|
||||
});
|
||||
// Registration belongs to package installation, not ordinary browser sessions.
|
||||
export default router;
|
||||
107
backend/src/routes/installedApps.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import {Router,raw,type Request,type Response} from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {allInstalled,findInstalled,matchInstalled,connectionFor,packageSummary,type Connection} from '../db/installedApps';
|
||||
import {requireAuth,requireAdmin,requireMutationCsrf} from '../middleware/auth';
|
||||
import type {AppRoute} from '../lib/appPackage';
|
||||
|
||||
export const installedRuntimeRouter=Router();
|
||||
installedRuntimeRouter.get('/resolve',requireAuth,(req,res)=>{
|
||||
const pathname=typeof req.query.path==='string'?req.query.path:'';
|
||||
const app=matchInstalled(pathname);
|
||||
res.setHeader('Cache-Control','no-store');
|
||||
if(!app){res.json(null);return;}
|
||||
if(app.manifest.application.audience==='admins'&&req.principal?.role!=='admin'){res.status(403).json({error:'Administrator access is required.'});return;}
|
||||
res.json(packageSummary(app));
|
||||
});
|
||||
export const installedAssetRouter=Router();
|
||||
installedAssetRouter.get('/:id/:digest/*',(req,res,next)=>{
|
||||
const app=findInstalled(req.params.id),name=(req.params as Record<string,string>)[0];
|
||||
if(!app||app.digest!==req.params.digest||!Object.prototype.hasOwnProperty.call(app.manifest.files,name)){res.status(404).end();return;}
|
||||
const serve=()=>{
|
||||
const file=path.join(app.directory,name);
|
||||
if(!fs.existsSync(file)){res.status(404).end();return;}
|
||||
res.setHeader('Cache-Control','private, max-age=31536000, immutable');
|
||||
res.setHeader('X-Content-Type-Options','nosniff');
|
||||
res.sendFile(file);
|
||||
};
|
||||
// Decorative icon files are public; executable assets retain application access rules.
|
||||
if(name===app.manifest.entry.icon){serve();return;}
|
||||
const guard=app.manifest.application.audience==='admins'?requireAdmin:requireAuth;
|
||||
guard(req,res,serve);
|
||||
});
|
||||
|
||||
function matches(pattern:string,pathname:string,c:Connection):boolean{
|
||||
const wanted=pattern.split('/'),got=pathname.split('/');
|
||||
return wanted.length===got.length&&wanted.every((segment,i)=>{
|
||||
if(segment.startsWith(':'))return /^[A-Za-z0-9_-]+$/.test(got[i]);
|
||||
const binding=segment.match(/^\{([A-Za-z][A-Za-z0-9]*)\}$/);
|
||||
return binding?got[i]===c.values[binding[1]]:segment===got[i];
|
||||
});
|
||||
}
|
||||
async function limitedBody(upstream:globalThis.Response):Promise<Buffer>{
|
||||
const chunks:Uint8Array[]=[];let length=0;
|
||||
if(upstream.body){const reader=upstream.body.getReader();try{while(true){const part=await reader.read();if(part.done)break;if((length+=part.value.byteLength)>2000000){await reader.cancel();throw new Error('Response too large');}chunks.push(part.value);}}finally{reader.releaseLock();}}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
async function forward(req:Request,res:Response,route:AppRoute,pathname:string,app:ReturnType<typeof allInstalled>[number],c:Connection){
|
||||
res.setHeader('Cache-Control','no-store');
|
||||
try{
|
||||
const origin=new URL(c.origin),target=new URL(origin.pathname.replace(/\/$/,'')+pathname,origin);
|
||||
for(const name of route.query||[]){const value=req.query[name];if(typeof value==='string'&&value.length<=8192)target.searchParams.set(name,value);}
|
||||
const headers:Record<string,string>={};
|
||||
if(route.access==='bearer')headers.Authorization=req.header('authorization')!;
|
||||
else if(c.token)headers.Authorization='Bearer '+c.token;
|
||||
for(const [name,source] of Object.entries(app.manifest.api?.headers||{})){
|
||||
if(route.access==='bearer')continue;
|
||||
const value=source==='principal:userId'?req.principal?.userId:source==='principal:role'?req.principal?.role:c.values[source.slice(7)];
|
||||
if(value)headers[name]=String(value);
|
||||
}
|
||||
for(const name of ['content-type','idempotency-key','x-upload-name']){const value=req.header(name);if(value)headers[name]=value;}
|
||||
const body=['GET','HEAD'].includes(req.method)?undefined:Buffer.isBuffer(req.body)?req.body:JSON.stringify(req.body||{});
|
||||
const upstream=await fetch(target,{method:req.method,headers,body,redirect:'manual',signal:AbortSignal.timeout(60000)});
|
||||
if(upstream.status>=300&&upstream.status<400){await upstream.body?.cancel();throw new Error('Redirects are not supported');}
|
||||
if(route.redirect){
|
||||
await upstream.body?.cancel();res.setHeader('Referrer-Policy','no-referrer');res.redirect(303,app.manifest.application.launchPath+(upstream.ok?'':'?connection=failed'));return;
|
||||
}
|
||||
const type=(upstream.headers.get('content-type')||'').split(';')[0];
|
||||
const data=await limitedBody(upstream);
|
||||
if(route.response==='image'&&upstream.ok){
|
||||
if(!['image/jpeg','image/png','image/webp'].includes(type))throw new Error('Unexpected response');
|
||||
res.type(type).status(upstream.status).send(data);return;
|
||||
}
|
||||
if(type!=='application/json')throw new Error('Unexpected response');
|
||||
const value=JSON.parse(data.toString('utf8'));
|
||||
// A backend error/echo must never return the host's internal connection token.
|
||||
if(c.token&&JSON.stringify(value).includes(c.token))throw new Error('Private connection reflected');
|
||||
res.status(upstream.status).json(value);
|
||||
}catch{
|
||||
// Never log callback query strings, connection tokens, upstream exceptions or request bodies.
|
||||
res.status(502).json({code:'APPLICATION_UNAVAILABLE',error:'The application service is unavailable. Refresh before retrying; your request may have been accepted.'});
|
||||
}
|
||||
}
|
||||
export const installedApiRouter=Router();
|
||||
installedApiRouter.use((req,res,next)=>{
|
||||
const app=allInstalled().find(item=>item.manifest.api&&(req.path===item.manifest.api.basePath||req.path.startsWith(item.manifest.api.basePath+'/')));
|
||||
if(!app)return next();
|
||||
res.setHeader('Cache-Control','no-store');
|
||||
const pathname=req.path.slice(app.manifest.api!.basePath.length);
|
||||
// Reject encoded separators and traversal rather than normalizing them into another allowed route.
|
||||
if(!/^\/[A-Za-z0-9_./-]*$/.test(pathname)||pathname.split('/').some(x=>x==='.'||x==='..')){res.status(404).json({error:'Not found.'});return;}
|
||||
let connection:Connection|undefined;
|
||||
try{connection=connectionFor(app.manifest.application.id);}catch{/* An unavailable server key fails closed. */}
|
||||
if(!connection){res.status(503).json({code:'APPLICATION_NOT_CONFIGURED',error:'The application connection is not configured.'});return;}
|
||||
const route=app.manifest.api!.routes.find(r=>r.methods.includes(req.method)&&matches(r.path,pathname,connection!));
|
||||
if(!route){res.status(404).json({error:'Not found.'});return;}
|
||||
const invoke=()=>raw({type:['image/jpeg','image/png','image/webp','text/csv'],limit:2000000})(req,res,error=>{
|
||||
if(error){res.status(413).json({error:'The upload exceeds the 2 MB limit.'});return;}
|
||||
void forward(req,res,route,pathname,app,connection!);
|
||||
});
|
||||
if(route.access==='public'){invoke();return;}
|
||||
if(route.access==='bearer'){
|
||||
if(!/^Bearer [^\s]{16,4096}$/.test(req.header('authorization')||'')){res.status(401).json({error:'An application API token is required.'});return;}
|
||||
invoke();return;
|
||||
}
|
||||
const guard=route.access==='admin'||app.manifest.application.audience==='admins'?requireAdmin:requireAuth;
|
||||
guard(req,res,()=>requireMutationCsrf(req,res,invoke));
|
||||
});
|
||||
@ -10,6 +10,8 @@ import {
|
||||
import { validateProjectDocument, ValidationIssue } from '../lib/validateProject';
|
||||
import { deleteInactivePublications, hasActivePublication } from '../db/publishedApps';
|
||||
|
||||
import db from '../db/database';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function validationFailure(res: Response, errors: ValidationIssue[], warnings: ValidationIssue[] = []): void {
|
||||
@ -53,6 +55,12 @@ router.post('/', (req: Request, res: Response) => {
|
||||
if ('error' in checked) { res.status(400).json({ code: 'PROJECT_JSON_INVALID', message: checked.error }); return; }
|
||||
if ('issues' in checked) { validationFailure(res, checked.issues, checked.warnings); return; }
|
||||
|
||||
if (project_json === undefined && typeof description === 'string') {
|
||||
const document = JSON.parse(checked.json);
|
||||
document.project.description = description;
|
||||
checked.json = JSON.stringify(document);
|
||||
}
|
||||
|
||||
const project = createProject({
|
||||
name: name.trim(),
|
||||
description: typeof description === 'string' ? description : undefined,
|
||||
@ -112,6 +120,29 @@ router.put('/:id', (req: Request, res: Response) => {
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
// Metadata changes preserve the latest canonical document and reject a stale list row.
|
||||
router.patch('/:id', (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
const {name, description, expectedUpdatedAt} = req.body ?? {};
|
||||
if (!Number.isInteger(id) || id < 1 || typeof name !== 'string' || !name.trim() || name.length > 200 ||
|
||||
typeof description !== 'string' || description.length > 2000 || typeof expectedUpdatedAt !== 'string') {
|
||||
res.status(400).json({error:'Provide a project name, description and the current saved revision.'}); return;
|
||||
}
|
||||
const result = db.transaction(() => {
|
||||
const current = getProjectById(id);
|
||||
if (!current) return {status:404, body:{error:'Project not found.'}};
|
||||
if (current.updated_at !== expectedUpdatedAt) return {status:409, body:{error:'This project changed. Refresh the list before updating its details.'}};
|
||||
const checked = parseAndValidateProjectJson(current.project_json);
|
||||
if ('error' in checked || 'issues' in checked) return {status:409, body:{error:'The stored project is invalid. Repair it before changing its details.'}};
|
||||
const document = JSON.parse(checked.json);
|
||||
document.project.name = name.trim();
|
||||
document.project.description = description;
|
||||
const row = updateProject(id, {name:name.trim(), description, project_json:JSON.stringify(document)});
|
||||
return {status:200, body:row};
|
||||
})();
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// DELETE /api/projects/:id — delete a project
|
||||
router.delete('/:id', (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
@ -120,6 +151,8 @@ router.delete('/:id', (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = req.header('x-project-updated-at');
|
||||
if (expected && getProjectById(id)?.updated_at !== expected) { res.status(409).json({error:'This project changed. Refresh the list before deleting it.'}); 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);
|
||||
|
||||
@ -10,11 +10,11 @@ type SnapshotPage={id:string;components:Array<{name:string}>};
|
||||
type Snapshot={schemaVersion:string;project:{id:string;name:string;description?:string;pages:SnapshotPage[];actions:RestActionInput[];bindings:unknown[];variables:Record<string,{type:string;defaultValue?:unknown;scope?:'global'|'page';pageId?:string}>;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 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,iconPath:row.icon_path,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.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,iconPath:row.icon_path,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;
|
||||
|
||||
31
backend/src/scripts/backupApplications.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import {DATA_DIR} from '../db/database';
|
||||
import {createHash} from 'crypto';
|
||||
function main(){
|
||||
const [snapshot,destination,...rest]=process.argv.slice(2);
|
||||
if(rest.length||!snapshot||!destination)throw new Error('Usage: backupApplications.js SNAPSHOT_DB DESTINATION');
|
||||
const output=path.resolve(destination);
|
||||
if(path.dirname(output)!==DATA_DIR||!/^\.conductor-backup-[A-Za-z0-9-]+-apps$/.test(path.basename(output))||fs.existsSync(output))throw new Error('Use a new Conductor backup staging directory inside its data directory.');
|
||||
const db=new Database(snapshot,{readonly:true,fileMustExist:true});
|
||||
try{
|
||||
const rows=db.prepare('SELECT app_id,digest,package_json FROM installed_packages').all() as {app_id:string;digest:string;package_json:string}[];
|
||||
fs.mkdirSync(output,{mode:0o700});
|
||||
for(const row of rows){
|
||||
if(!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(row.app_id)||! /^[a-f0-9]{64}$/.test(row.digest))throw new Error('Invalid installed package identity.');
|
||||
const manifest=JSON.parse(row.package_json),source=path.join(DATA_DIR,'applications',row.app_id,row.digest),target=path.join(output,row.app_id,row.digest);
|
||||
fs.mkdirSync(target,{recursive:true,mode:0o700});
|
||||
for(const [name,hash] of Object.entries(manifest.files as Record<string,string>)){
|
||||
const file=path.resolve(source,name);
|
||||
if(!file.startsWith(source+path.sep)||fs.lstatSync(file).isSymbolicLink())throw new Error('Invalid installed asset.');
|
||||
const bytes=fs.readFileSync(file);
|
||||
if(createHash('sha256').update(bytes).digest('hex')!==hash)throw new Error('An installed application file does not match its checksum.');
|
||||
const out=path.join(target,name);fs.mkdirSync(path.dirname(out),{recursive:true,mode:0o700});fs.writeFileSync(out,bytes,{mode:0o600});
|
||||
}
|
||||
fs.writeFileSync(path.join(target,'conductor-app.json'),row.package_json,{mode:0o600});
|
||||
}
|
||||
console.log(JSON.stringify({applications:rows.length}));
|
||||
}finally{db.close();}
|
||||
}
|
||||
try{main();}catch(error){console.error(error instanceof Error?error.message:'Application backup failed.');process.exitCode=1;}
|
||||
35
backend/src/scripts/includedApps.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { initDatabase } from '../db/init';
|
||||
import db from '../db/database';
|
||||
import { registerIncludedApp, listIncludedApps, unregisterIncludedApp } from '../db/includedApps';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [command, id, ...rest] = process.argv.slice(2);
|
||||
if (rest.length || !['register', 'list', 'unregister'].includes(command) ||
|
||||
(command === 'unregister' ? !id : id !== undefined)) {
|
||||
throw new Error('Usage: node dist/scripts/includedApps.js register < manifest.json | list | unregister APP_ID');
|
||||
}
|
||||
let manifest: unknown;
|
||||
if (command === 'register') {
|
||||
const chunks: Buffer[] = []; let size = 0;
|
||||
for await (const chunk of process.stdin) {
|
||||
const bytes = Buffer.from(chunk); size += bytes.length;
|
||||
if (size > 16_384) throw new Error('Application manifests must be at most 16 KiB.');
|
||||
chunks.push(bytes);
|
||||
}
|
||||
try { manifest = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
||||
catch { throw new Error('Supply a valid JSON manifest on standard input.'); }
|
||||
}
|
||||
initDatabase(false);
|
||||
if (command === 'register') {
|
||||
const app = registerIncludedApp(manifest);
|
||||
console.log(JSON.stringify({registered: app.id, version: app.version}));
|
||||
} else if (command === 'list') {
|
||||
console.log(JSON.stringify(listIncludedApps(true), null, 2));
|
||||
} else {
|
||||
console.log(JSON.stringify({id, removed: unregisterIncludedApp(id)}));
|
||||
}
|
||||
}
|
||||
void main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : 'Application registration failed.');
|
||||
process.exitCode = 1;
|
||||
}).finally(() => db.close());
|
||||
17
backend/src/scripts/installedApps.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import {initDatabase} from '../db/init';
|
||||
import db from '../db/database';
|
||||
import {allInstalled,installPackage,removePackage} from '../db/installedApps';
|
||||
async function main(){
|
||||
const [command,target,...rest]=process.argv.slice(2);
|
||||
if(rest.length||!['capabilities','list','install','remove'].includes(command)||(['install','remove'].includes(command)?!target:target!==undefined))throw new Error('Usage: installedApps.js capabilities | list | install PACKAGE_DIR < connection.json | remove APP_ID');
|
||||
if(command==='capabilities'){console.log(JSON.stringify({hostApiVersion:1,persistentPackages:true,version:process.env.CONDUCTOR_VERSION||'development'}));return;}
|
||||
initDatabase(false);
|
||||
if(command==='list'){console.log(JSON.stringify(allInstalled().map(x=>({id:x.manifest.application.id,version:x.manifest.application.version,digest:x.digest})),null,2));return;}
|
||||
if(command==='remove'){console.log(JSON.stringify(removePackage(target)));return;}
|
||||
const chunks:Buffer[]=[];let size=0;
|
||||
for await(const chunk of process.stdin){const b=Buffer.from(chunk);if((size+=b.length)>16384)throw new Error('Connection input exceeds 16 KiB.');chunks.push(b);}
|
||||
const text=Buffer.concat(chunks).toString('utf8').trim();
|
||||
let connection:unknown;try{connection=text?JSON.parse(text):undefined;}catch{throw new Error('Supply valid connection JSON on standard input.');}
|
||||
console.log(JSON.stringify(installPackage(target,connection)));
|
||||
}
|
||||
void main().catch(error=>{console.error(error instanceof Error?error.message:'Package installation failed.');process.exitCode=1;}).finally(()=>db.close());
|
||||
@ -1,6 +1,6 @@
|
||||
services:
|
||||
conductor:
|
||||
image: ${CONDUCTOR_IMAGE:-gitea.skeletonworks.online/vwiebe/conductor}:${CONDUCTOR_VERSION:-v1.0.0}
|
||||
image: ${CONDUCTOR_IMAGE:-gitea.skeletonworks.online/vwiebe/conductor}:${CONDUCTOR_VERSION:-v1.1.0}
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
ports:
|
||||
|
||||
16
design/icon/README.md
Normal file
@ -0,0 +1,16 @@
|
||||
# Temporary Conductor icon
|
||||
|
||||
A white C and a raised conductor's baton on a rounded blue square.
|
||||
|
||||
The blue (#165BBA) matches the current Conductor interface. It is a temporary application identity, not a Skeleton Works brand standard.
|
||||
|
||||
## Files
|
||||
|
||||
- conductor.svg: editable vector master, with no fonts or external resources.
|
||||
- conductor-16.png, conductor-32.png, conductor-48.png: browser icon sizes.
|
||||
- conductor-180.png: touch icon size.
|
||||
- conductor-192.png, conductor-512.png: larger application icon sizes.
|
||||
- favicon.ico: a multi-size 16/32/48 icon with 32-bit colour and transparency.
|
||||
- preview.html and preview.png: small and enlarged previews on light and dark backgrounds.
|
||||
|
||||
SLICE10 connects this approved artwork to frontend/public: conductor.svg becomes favicon.svg, favicon.ico provides the conventional fallback, conductor-32.png becomes favicon-32.png, and conductor-180.png becomes apple-touch-icon.png. The shared browser identity uses the SVG default unless an application supplies its own icon. Release and VM installation work remains in SLICE11–SLICE13.
|
||||
BIN
design/icon/conductor-16.png
Normal file
|
After Width: | Height: | Size: 462 B |
BIN
design/icon/conductor-180.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
design/icon/conductor-192.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
design/icon/conductor-32.png
Normal file
|
After Width: | Height: | Size: 838 B |
BIN
design/icon/conductor-48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
design/icon/conductor-512.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
6
design/icon/conductor.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title id="title">Conductor</title>
|
||||
<rect x="2" y="2" width="60" height="60" rx="14" fill="#165BBA"/>
|
||||
<path d="M41 19 A16 16 0 1 0 41 45" fill="none" stroke="#FFF" stroke-width="7.5" stroke-linecap="round"/>
|
||||
<path d="M33 32 L49 19" fill="none" stroke="#FFF" stroke-width="4.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 436 B |
BIN
design/icon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
13
design/icon/preview.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Conductor icon preview</title>
|
||||
<link rel="icon" type="image/svg+xml" href="conductor.svg">
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#f3f6fb;color:#1f2937;font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}main{max-width:1120px;padding:44px 36px 38px;margin:auto}h1{font-size:30px;letter-spacing:-.6px;margin:0 0 8px}p{margin:0;color:#59677a}.layout{display:grid;grid-template-columns:290px 1fr;gap:24px;margin-top:30px}.hero,.panel{border:1px solid #dce3ee;border-radius:18px;background:white}.hero{display:flex;align-items:center;justify-content:center;flex-direction:column;gap:22px;padding:44px 24px}.hero img{width:168px;height:168px}.hero strong{font-size:24px}.stack{display:grid;gap:18px}.panel{padding:24px}h2{font-size:15px;letter-spacing:.2px;margin:0 0 16px}.tab{display:flex;align-items:center;gap:10px;min-height:46px;border-radius:9px;background:#eef2f8;border:1px solid #dce3ee;padding:10px 14px;max-width:330px;font-size:14px}.tab img{width:16px;height:16px}.dark{background:#20252d;color:#f1f5fa;border-color:#20252d}.dark .tab{background:#333b48;border-color:#414c5d}.sizes{display:flex;align-items:flex-end;gap:34px;margin-top:24px}.sizes figure{margin:0;text-align:center}.sizes img{display:block;margin:auto}.sizes figcaption{font-size:12px;color:#637085;margin-top:8px}.note{margin-top:24px;font-size:14px}footer{display:flex;justify-content:space-between;gap:20px;font-size:13px;color:#637085;margin-top:22px}
|
||||
</style></head><body><main>
|
||||
<h1>A small identity for Conductor</h1><p>A simple C with a raised baton, using the blue already in the application.</p>
|
||||
<div class="layout"><section class="hero" aria-label="Enlarged icon"><img src="conductor.svg" alt="Blue rounded square with a white C and diagonal baton"><strong>Conductor</strong></section>
|
||||
<div class="stack"><section class="panel"><h2>Browser-tab size · light background</h2><div class="tab"><img src="conductor-16.png" alt="">Conductor</div>
|
||||
<div class="sizes"><figure><img width="16" height="16" src="conductor-16.png" alt="16 pixel icon"><figcaption>16 px</figcaption></figure><figure><img width="32" height="32" src="conductor-32.png" alt="32 pixel icon"><figcaption>32 px</figcaption></figure><figure><img width="48" height="48" src="conductor-48.png" alt="48 pixel icon"><figcaption>48 px</figcaption></figure></div>
|
||||
</section><section class="panel dark"><h2>Browser-tab size · dark background</h2><div class="tab"><img src="conductor-16.png" alt="">Conductor</div></section></div></div>
|
||||
<footer><span>Temporary application icon · SVG, PNG and ICO</span><span>Blue #165BBA · White #FFFFFF</span></footer>
|
||||
</main></body></html>
|
||||
BIN
design/icon/preview.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
@ -12,7 +12,7 @@ Conductor has global roles rather than per-project or per-application permission
|
||||
| User | Public and restricted applications in the catalog; own password change. |
|
||||
| Administrator | All user access plus project authoring, validation, action testing, credentials, execution history, publishing, and user management. |
|
||||
|
||||
The backend enforces these boundaries even if someone constructs an API request directly. Conductor v1.0.0 does not provide groups, custom roles, per-application assignments, self-registration, email invitations, or email-based password recovery.
|
||||
The backend enforces these boundaries even if someone constructs an API request directly. Conductor v1.1.0 does not provide groups, custom roles, per-application assignments, self-registration, email invitations, or email-based password recovery.
|
||||
|
||||
## Initialize a new installation
|
||||
|
||||
@ -46,7 +46,7 @@ Disabling an account, changing its role, resetting its password, or explicitly r
|
||||
|
||||
Users change their own password beneath the application catalog. That operation rotates the current session and revokes their other sessions. Administrators can reset their own account from **Users**, but should keep another working administrator available before doing so.
|
||||
|
||||
Account deletion is not exposed in v1.0.0. Disable an account when access should be retained only for historical attribution.
|
||||
Account deletion is not exposed in v1.1.0. Disable an account when access should be retained only for historical attribution.
|
||||
|
||||
## Manage stored credentials
|
||||
|
||||
@ -70,9 +70,9 @@ To remove a credential safely:
|
||||
3. Republish each application that should remain live, or unpublish it. Published snapshots are immutable and can retain the old reference until this step is complete.
|
||||
4. Expand the credential and select **Delete**.
|
||||
|
||||
Deletion is blocked by references in saved projects, but v1.0.0 does not perform that same check against immutable published snapshots. Do not delete first and leave a live restricted application with a missing credential.
|
||||
Deletion is blocked by references in saved projects, but v1.1.0 does not perform that same check against immutable published snapshots. Do not delete first and leave a live restricted application with a missing credential.
|
||||
|
||||
Credential values are encrypted with `CONDUCTOR_SECRET_KEY`. A usable disaster-recovery archive must contain both the database and its matching key. See the [Secrets Security Model](SECRETS.md) and [Backup and restore](OPERATIONS.md#backup).
|
||||
Credential values are encrypted with AES-256-GCM using `CONDUCTOR_SECRET_KEY`. The browser and project JSON receive only opaque secret references, and an existing value can never be retrieved through Conductor. A usable disaster-recovery archive must contain both the database and its matching key. See [Backup](OPERATIONS.md#backup).
|
||||
|
||||
## Publish and withdraw applications
|
||||
|
||||
@ -111,7 +111,7 @@ Conductor retains the newest 1,000 executions globally. It does not store reques
|
||||
|
||||
Project authors can call public `http` and `https` endpoints that pass Conductor's destination and request policy. Private, loopback, link-local, metadata, and other unsafe address ranges are blocked by default.
|
||||
|
||||
An in-application administrator cannot weaken that policy. A host operator may add a reviewed exact origin to `CONDUCTOR_PROXY_INTERNAL_ORIGINS`; wildcards and URL paths are not accepted. Use the procedure for the deployment style in [Production configuration](OPERATIONS.md#production-configuration) and the detailed [Proxy Security Policy](PROXY_SECURITY.md).
|
||||
An in-application administrator cannot weaken that policy. A host operator may add a reviewed exact origin to `CONDUCTOR_PROXY_INTERNAL_ORIGINS`; wildcards, URL paths, embedded credentials, and IP literals are not accepted. Use [Production configuration](OPERATIONS.md#production-configuration).
|
||||
|
||||
## Recover administrative access
|
||||
|
||||
@ -132,4 +132,37 @@ Operator recovery can target only an existing local administrator. It cannot ele
|
||||
- Confirm scheduled backups complete, copy them off-host, and periodically test a restore.
|
||||
- Install upgrades only from pinned images after reading release notes and taking a backup.
|
||||
|
||||
For symptom-based help, see [Troubleshooting](TROUBLESHOOTING.md). For implemented boundaries, see [Known Limitations](KNOWN_LIMITATIONS.md).
|
||||
For symptom-based help, see [Troubleshooting](TROUBLESHOOTING.md). Product boundaries are summarized in [v1.1.0 limitations](USER_GUIDE.md#v100-limitations).
|
||||
|
||||
## Security boundaries
|
||||
|
||||
- Authentication establishes an identity; backend authorization independently checks the account's current enabled state and role on every protected request.
|
||||
- Passwords use salted `scrypt` hashes. Sessions are random, server-side, hashed at rest, rotated after sensitive account changes, and protected by secure cookies and CSRF tokens.
|
||||
- All authoring, validation, credential, history-management, user-management, and publication-management APIs require an administrator. Hiding navigation in the browser is not the security boundary.
|
||||
- Publishing stores an immutable validated snapshot. Public snapshots may use only anonymous actions. Restricted published clients submit runtime values to server-owned action IDs and never receive draft JSON, secret references, authoritative URLs, or authentication configuration.
|
||||
- Execution history stores at most 1,000 sanitized records. It excludes request and response headers, cookies, credential values, sensitive query values, DNS answers, private addresses, and stack traces.
|
||||
- Conductor does not isolate mutually hostile administrators or protect a host whose backend process, database plus encryption key, or operating system has been compromised.
|
||||
|
||||
## Included with Skeleton Works
|
||||
|
||||
Publishing now has separate sections for applications you publish from saved projects and applications installed on the VM. Installed applications appear only after their installer registers them. Use **Open** to launch one and **Configure**, when supplied, for its administration screen. Installed applications do not have Republish or Unpublish controls; their installer owns updates and removal.
|
||||
|
||||
For the manifest and VM administration commands, see [Included applications](INCLUDED_APPLICATIONS.md). Conductor contains no default Social Scheduler entry.
|
||||
|
||||
## Application icons and browser titles (v1.1.0)
|
||||
|
||||
These features are included in Conductor v1.1.0.
|
||||
|
||||
Conductor uses its blue C-and-baton icon by default. The browser tab shows **Conductor** at home, the screen name while editing (for example, **Publishing · Conductor**), or the application's name (for example, **Workshop Notes · Conductor**).
|
||||
|
||||
To choose an icon when publishing:
|
||||
|
||||
1. Open **Publishing** and select the saved project, application name, address and access setting. This identifies the application being published.
|
||||
2. Under **Application icon (optional)**, choose a square PNG between 16 and 512 pixels and no larger than 256 KB (262,144 bytes). The preview confirms the image was accepted; leave the field empty to use Conductor's icon.
|
||||
3. Select **Publish**, then **Open app**. The application card and browser tab should show the chosen icon and application name.
|
||||
|
||||
To change an existing application's icon, expand **Application icon** on its Publishing card and choose a PNG. This saves the icon immediately. Select **Use Conductor icon** to reset it. Reload an already-open application tab to see a changed icon. Republish preserves the chosen icon; changing an icon does not publish changes to the source project or change its access setting.
|
||||
|
||||
Uploads are stored with Conductor's database on the customer VM. They need no S3 service. They are public decorative images, including when assigned to restricted applications, so choose a logo or symbol suitable for public display. Uploading an icon requires an administrator session and the same request protection as other administrative changes.
|
||||
|
||||
Installed applications receive their name and optional icon from their installer. Their icons are not edited through the project-publication controls. See the [installed-application contract](INCLUDED_APPLICATIONS.md).
|
||||
|
||||
97
docs/APP_PACKAGES.md
Normal file
@ -0,0 +1,97 @@
|
||||
# Installed application packages — host API v1
|
||||
|
||||
Conductor v1.1.0 can install browser applications independently of its image. An installed package contains browser assets, application metadata, checksums and optional API route declarations. It contains no server-side module that Conductor executes.
|
||||
|
||||
This interface is for trusted Skeleton Works packages installed by the VM administrator. Package JavaScript runs on the Conductor origin and has the signed-in user's browser privileges. It is not a sandbox or a marketplace for untrusted uploads. Customers who can administer the VM already control its software.
|
||||
|
||||
## Files and identity
|
||||
|
||||
A package directory contains conductor-app.json and its declared assets. See [the minimal example](../examples/installed-app/conductor-app.json) and [its browser entry](../examples/installed-app/app.js).
|
||||
|
||||
- packageVersion and hostApiVersion are both 1.
|
||||
- application uses the existing included-app metadata: schemaVersion, stable id, name, description, version, launchPath, optional configurationPath, and audience.
|
||||
- launchPath is one non-reserved top-level path such as /example-application. Query views and descendant paths are handled by that app.
|
||||
- entry.script names an ES module. entry.style and entry.icon are optional.
|
||||
- files maps each packaged asset path to its SHA-256 checksum.
|
||||
- api, when present, declares the app's own /api/APP_ID namespace and allowlisted routes.
|
||||
|
||||
Limits: manifest 64 KiB, 1–200 declared assets, 10 MiB per asset and 20 MiB total. Relative paths and checksums are checked, symlinks are rejected, and package route collisions are refused. A malformed update leaves the currently selected package in place.
|
||||
|
||||
The browser entry exports mount(element, context). It returns an unmount function to release timers, listeners and its UI. Context contains hostApiVersion, public application metadata and the signed-in user's id, displayName and role. It contains no backend token, origin, connection configuration or social credentials.
|
||||
|
||||
Conductor loads the package's module and optional stylesheet, retains the shared title/favicon behavior, and removes the app UI/style on unmount. Give each version its own content checksums. Packages should scope their styles and bundle their runtime dependencies; the host does not supply a particular React version. Native ES-module loading uses [webpack's documented import exclusion](https://webpack.js.org/api/module-methods/#magic-comments).
|
||||
|
||||
## Install using the running container
|
||||
|
||||
Stage a verified package through the running container. This works with the read-only root filesystem and its writable /tmp mount:
|
||||
|
||||
docker exec CONDUCTOR_CONTAINER mkdir -m 700 /tmp/example-package
|
||||
tar -C /path/to/package -cf - . | docker exec -i CONDUCTOR_CONTAINER tar -xf - -C /tmp/example-package
|
||||
|
||||
Then install it:
|
||||
|
||||
docker exec -i -w /app CONDUCTOR_CONTAINER \
|
||||
node dist/scripts/installedApps.js install /tmp/example-package < connection.json
|
||||
|
||||
For an app without an API, provide empty standard input instead of a connection file. No Node.js installation is needed on the VM host.
|
||||
|
||||
The connection file is private server configuration:
|
||||
|
||||
{
|
||||
"origin": "http://application-backend:8080/service",
|
||||
"token": "REPLACE_WITH_THE_EXISTING_INTERNAL_SERVICE_TOKEN",
|
||||
"values": {"workspaceId": "customer"}
|
||||
}
|
||||
|
||||
The origin is a fixed HTTP(S) backend address. It cannot contain URL credentials, a query or a fragment. Keep this input out of shell arguments, logs and browser assets. Conductor encrypts it in SQLite using its existing persistent secret key. Social-network credentials remain in the backend that owns them, such as Node-RED.
|
||||
|
||||
Installation verifies every declared asset, stages immutable files beneath /data/applications/APP_ID/DIGEST, and atomically selects the package, connection and catalog metadata in SQLite. When updating, omitted connection input preserves the existing connection. Installing identical content again reuses its digest. An installer should health-check its backend before selecting the package. No Conductor restart or rebuild is needed.
|
||||
|
||||
Other commands:
|
||||
|
||||
docker exec -w /app CONDUCTOR_CONTAINER node dist/scripts/installedApps.js capabilities
|
||||
docker exec -w /app CONDUCTOR_CONTAINER node dist/scripts/installedApps.js list
|
||||
docker exec -w /app CONDUCTOR_CONTAINER node dist/scripts/installedApps.js remove APP_ID
|
||||
|
||||
Capabilities reports hostApiVersion 1. List returns package IDs, versions and digests without connection secrets. Remove unselects the package, removes its connection and catalog entry, and retains immutable files for recovery. It does not delete the application's own database, accounts or posts. Automatic removal of unused package files is deferred.
|
||||
|
||||
## API declaration
|
||||
|
||||
Each route declares path, methods and access. Literal path segments match exactly. A segment such as :postId permits letters, digits, underscores and hyphens. A segment such as {workspaceId} must equal the named value in the server-side connection. Encoded separators, traversal and unlisted operations are rejected.
|
||||
|
||||
Access modes:
|
||||
|
||||
| Mode | Enforcement |
|
||||
|---|---|
|
||||
| user | An enabled Conductor session; mutation requests also need its CSRF token. |
|
||||
| admin | The same checks plus Conductor administrator role. |
|
||||
| public | Read-only GET routes explicitly intended for public metadata. |
|
||||
| bearer | A caller-supplied Bearer token is forwarded to the application backend, which validates its scopes. Conductor's internal token and user identity are not added. |
|
||||
|
||||
An admins-only application also requires administrator access for its signed-in API routes and executable assets. Public decorative icons may be read without a session.
|
||||
|
||||
The optional headers map permits X- headers bound to principal:userId, principal:role or config:VALUE_NAME. The bridge supplies these from trusted server context, ignoring caller attempts to spoof identity. For signed-in/public routes it supplies the internal service token. It forwards only Content-Type, Idempotency-Key and X-Upload-Name from other caller headers.
|
||||
|
||||
Optional query lists the only query parameters forwarded. JSON is the default response; response: image permits JPEG, PNG and WebP responses. Requests/responses are bounded to approximately 2 MB and backend requests time out after 60 seconds. Upstream redirects are rejected. There is no automatic retry after an uncertain request.
|
||||
|
||||
A signed-in GET route with redirect: true handles an OAuth-style callback. Its allowlisted query is sent to the backend, the response body is discarded, and Conductor redirects only to that app's launch path, with connection=failed on failure. Callback requests bypass access logging and the redirect uses a no-referrer policy.
|
||||
|
||||
Application packages do not bypass Node-RED authorization. The backend remains responsible for account access, token scopes, scheduling rules and social-network behavior.
|
||||
|
||||
## Persistence and recovery
|
||||
|
||||
The database contains active package metadata and encrypted connections; /data/applications contains immutable browser files. Both reside inside the existing persistent Conductor data volume.
|
||||
|
||||
The v1.1.0 backup tools use conductor-backup-v2 and include the exact app assets referenced by their SQLite snapshot, plus the persistent keys and Compose metadata. Restore supports v1 and v2; old v1 backups contain no independently installed app assets. Keep matching images, data, packages and keys together. App removal retains files, so restoring metadata does not depend on a remote package registry.
|
||||
|
||||
## Verification and migration
|
||||
|
||||
After building Conductor and Social Scheduler's package, run:
|
||||
|
||||
node scripts/test-installed-apps.mjs /absolute/path/to/social-scheduler
|
||||
|
||||
This verifies a fresh empty host, the real scheduler frontend, a second independent app, updates/removal/restarts, access roles, CSRF, bearer delegation, fixed workspaces, query filtering, response bounds, callback privacy and encrypted connection storage. The production lifecycle check additionally tests app-file backup/restore and container recreation.
|
||||
|
||||
Social Scheduler 0.5.0 uses this interface. Its /social-scheduler and /api/social-scheduler paths are unchanged. The previous 0.4.0 kit built a combined Conductor image; simply replacing that image is not a complete migration. Preserve its configuration and data, update the core through the supported upgrade procedure, then install the 0.5.0 package with the existing Node-RED connection. Fresh setup changes are tracked in [SLICE12](../SLICE12.md); the existing-VM upgrade procedure is [SLICE13](../SLICE13.md).
|
||||
|
||||
The bridge rejects a JSON response that reflects its private connection token, including backend error/echo responses. It returns a generic service error and never logs the upstream body.
|
||||
@ -1,46 +0,0 @@
|
||||
# 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.
|
||||
@ -1,21 +0,0 @@
|
||||
# First-Run Administration
|
||||
|
||||
On an empty installation, finish HTTPS and open Conductor in a browser. The setup screen creates the first administrator and signs that administrator in. Conductor ships with no default username or password.
|
||||
|
||||
The setup form requires a unique username and a password of at least 12 characters. Display name and contact email are optional. Passwords are masked, confirmed in the browser, hashed before storage, and never returned or logged.
|
||||
|
||||
Initial creation is protected by a short-lived same-site setup token, request throttling, and an atomic empty-database check. Concurrent setup attempts can create at most one administrator. The setup endpoint closes permanently as soon as any user exists; it is not a registration endpoint.
|
||||
|
||||
After signing in:
|
||||
|
||||
1. Open **Users** and create a second administrator for recovery.
|
||||
2. Store administrator credentials through an approved private process.
|
||||
3. Confirm the installation has a current off-host backup before adding projects or stored REST credentials.
|
||||
|
||||
See the [Administrator Guide](ADMIN_GUIDE.md) for account and application administration.
|
||||
|
||||
## CLI fallback and recovery
|
||||
|
||||
Browser setup is the normal path. A host operator can explicitly bootstrap an empty database or recover one existing local administrator by running the compiled production command documented in [Administrative bootstrap and recovery](OPERATIONS.md#administrative-bootstrap-and-recovery).
|
||||
|
||||
Recovery cannot create another account after setup or elevate a normal user. It targets an existing administrator, re-enables it, replaces its password, and revokes its sessions.
|
||||
89
docs/INCLUDED_APPLICATIONS.md
Normal file
@ -0,0 +1,89 @@
|
||||
# Included applications
|
||||
|
||||
Conductor has two kinds of application entries:
|
||||
|
||||
- **Applications you publish** are immutable snapshots of saved Conductor projects. Administrators publish, republish and unpublish them.
|
||||
- **Included with Skeleton Works** lists applications installed on the customer VM. Their installers own their code, configuration and lifecycle.
|
||||
|
||||
A fresh Conductor database has no included applications. Conductor does not seed Social Scheduler or any other product. Administrators see installed applications in Publishing and on the home page; ordinary users see eligible applications in their application catalog.
|
||||
|
||||
## Installer manifest
|
||||
|
||||
Each application ships a small JSON file. For example:
|
||||
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "example-application",
|
||||
"name": "Example Application",
|
||||
"description": "A short description for customers.",
|
||||
"version": "1.0.0",
|
||||
"launchPath": "/example-application",
|
||||
"configurationPath": "/example-application?view=settings",
|
||||
"audience": "all-users"
|
||||
}
|
||||
|
||||
The ID is a stable lowercase name with optional hyphens. Upgrades reuse it. The version describes the installed application. The optional configuration link is shown only to Conductor administrators. Audience is either all-users or admins.
|
||||
|
||||
Paths must be local absolute paths on the customer's Conductor origin. Full external URLs, protocol-relative URLs, traversal, API paths and credentials are rejected. No customer hostname belongs in the manifest. No secrets or social-media credentials belong in this file.
|
||||
|
||||
The current format rejects unknown fields and accepts manifests up to 16 KiB. A future format change must explicitly version the contract.
|
||||
|
||||
## Register after successful installation
|
||||
|
||||
The installer first deploys the application and verifies its routes and backend health. Only then should it register its manifest. Use the actual Conductor container name from the successful setup-conductor result:
|
||||
|
||||
docker exec -i -w /app CONDUCTOR_CONTAINER \
|
||||
node dist/scripts/includedApps.js register < included-app.json
|
||||
|
||||
Purpose: record a successfully installed application. Expected result: JSON containing its registered ID and version. Repeating registration updates the existing entry; it does not create duplicates or affect other entries.
|
||||
|
||||
The command uses the same persistent SQLite database as Conductor. It runs inside the container, so Node.js is not needed on the VM host. Registration changes catalog metadata only; it does not install code, create a builder project, grant application permissions, or store credentials.
|
||||
|
||||
The registration command manages discovery metadata only. In Conductor v1.1.0, new integrations use the [application package interface](APP_PACKAGES.md), which installs their browser assets and declared API routes independently of the core image. Legacy metadata-only entries remain supported for compatibility; use the package installer to update a packaged application.
|
||||
|
||||
## Inspect, remove and recover
|
||||
|
||||
docker exec -w /app CONDUCTOR_CONTAINER node dist/scripts/includedApps.js list
|
||||
|
||||
Purpose: inspect installed metadata without changing it. Expected result: a JSON array, including administrator-only entries.
|
||||
|
||||
docker exec -w /app CONDUCTOR_CONTAINER \
|
||||
node dist/scripts/includedApps.js unregister example-application
|
||||
|
||||
Purpose: remove the catalog entry when uninstalling that application. Expected result: its ID and whether an entry was removed. Repeating removal is safe. It does not delete application data or uninstall packages.
|
||||
|
||||
Application uninstallers must unregister their own ID. Manually deleting containers or code does not automatically update the catalog. Registration is evidence of a successful installation, not a live availability monitor.
|
||||
|
||||
The registry is covered by Conductor's normal SQLite backups. Retain matching images, manifests and application data when restoring an installation. Restoring only one side can leave links to missing code.
|
||||
|
||||
## Browser API and authorization
|
||||
|
||||
GET /api/included-apps requires a signed-in, enabled Conductor account. Administrators receive all entries. Ordinary users receive only all-users entries, without configuration links. Responses disable caching.
|
||||
|
||||
There is no public registration API or browser install button. Registration is a VM administration action through the container command. Catalog visibility does not replace each application's own backend authorization. An admins audience controls discovery; the application's routes must still enforce administrator access.
|
||||
|
||||
## Verification
|
||||
|
||||
After building the frontend and backend, run:
|
||||
|
||||
node scripts/test-management.mjs
|
||||
|
||||
This starts an isolated SQLite-backed test server and exercises home navigation, Projects, publishing, empty catalogs, repeated registration, removal, role filtering and error recovery in Chromium. Screenshots are written to test-results/management. Backend integration tests also verify the manifest rules, authenticated API, persistence across restart and stale project edits.
|
||||
|
||||
## Optional application icon and browser identity (SLICE10)
|
||||
|
||||
Conductor v1.1.0 accepts an optional **iconPath** in schemaVersion 1 manifests. Existing manifests remain valid and receive the Conductor default icon. This is an additive metadata field; incompatible future format changes still require a new schema version.
|
||||
|
||||
For example, an installer can add:
|
||||
|
||||
"iconPath": "/example-application/assets/icon.svg"
|
||||
|
||||
Serve that decorative image on the customer's Conductor origin before registering it. Supported extensions are SVG, PNG, ICO, WebP, JPG and JPEG. Paths must be local absolute paths up to 512 characters using letters, digits, underscores, hyphens, dots and slashes. External/protocol-relative URLs, traversal, API paths, encoded paths, query strings and fragments are rejected. The icon should be readable without a login so the browser can load it consistently. Never put tokens or credentials in its path.
|
||||
|
||||
The registration command validates the path; it does not install or copy the image. Missing or unreadable images fall back to Conductor's default. Package the image with the application and give a changed image a new filename to avoid an old browser cache. Re-register the same application ID to update its metadata, then refresh an open application tab.
|
||||
|
||||
The shared browser identity mechanism uses the registered **name** and **iconPath** for the launch route, its query-string views and descendant routes. The most specific registered route wins; a route such as /example-application does not match /example-application-two. Core home and /apps/... published routes retain their own identity.
|
||||
|
||||
Application cards use the same optional icon. Authenticated catalog responses include the icon only for entries visible to that user. The base application contains no Social Scheduler-specific title or icon. The existing Social Scheduler manifest works without modification and inherits the Conductor default.
|
||||
|
||||
This remains discovery and identity metadata. Independent installation is provided by [the v1.1.0 package interface](APP_PACKAGES.md), implemented in [SLICE11](../SLICE11.md).
|
||||
@ -4,11 +4,11 @@ This guide is for independent self-hosters installing Conductor on an ordinary i
|
||||
|
||||
The recommended path runs the published container. The tagged source bundle supplies the matching Compose file, environment template, backup/restore scripts, and documentation.
|
||||
|
||||
Use the `v1.0.0` commands below only after the public Gitea Release entry exists. Before publication, do not substitute an older release candidate and treat a missing source tag or image as an incomplete release rather than an installation problem.
|
||||
Use the `v1.1.0` commands below only after the public Gitea Release entry exists. Before publication, do not substitute an older release candidate and treat a missing source tag or image as an incomplete release rather than an installation problem.
|
||||
|
||||
## What you need
|
||||
|
||||
- A GNU/Linux `x86_64`/AMD64 server. Conductor v1.0.0 is not published for ARM64.
|
||||
- A GNU/Linux `x86_64`/AMD64 server. Conductor v1.1.0 is not published for ARM64.
|
||||
- Docker Engine with the `docker compose` plugin. Use Docker's [Engine installation guide](https://docs.docker.com/engine/install/) and [Compose plugin guide](https://docs.docker.com/compose/install/linux/) if needed.
|
||||
- Permission to run Docker commands.
|
||||
- `curl`, `openssl`, `tar`, `bash`, `realpath`, `find`, `sha256sum`, and GNU `stat`.
|
||||
@ -35,7 +35,7 @@ Choose a directory that will remain on the server. The example uses the current
|
||||
mkdir -p "$HOME/conductor"
|
||||
cd "$HOME/conductor"
|
||||
|
||||
CONDUCTOR_RELEASE=v1.0.0
|
||||
CONDUCTOR_RELEASE=v1.1.0
|
||||
curl --fail --location --show-error \
|
||||
"https://gitea.skeletonworks.online/vwiebe/conductor/archive/${CONDUCTOR_RELEASE}.tar.gz" \
|
||||
--output conductor-source.tar.gz
|
||||
@ -43,7 +43,7 @@ tar -xzf conductor-source.tar.gz --strip-components=1
|
||||
rm conductor-source.tar.gz
|
||||
```
|
||||
|
||||
The `v1.0.0` source tag and the container image's OCI `revision` label must identify the same release commit. The authoritative commit, archive checksums, and image digests are recorded in the Gitea `v1.0.0` Release entry after publication. Keep `compose.production.yml`, `.env`, and `scripts/production/` together so the documented backup and restore commands work.
|
||||
The `v1.1.0` source tag and the container image's OCI `revision` label must identify the same release commit. The authoritative commit, archive checksums, and image digests are recorded in the Gitea `v1.1.0` Release entry after publication. Keep `compose.production.yml`, `.env`, and `scripts/production/` together so the documented backup and restore commands work.
|
||||
|
||||
## 2. Create the persistent configuration
|
||||
|
||||
@ -61,7 +61,7 @@ session_key="$(openssl rand -hex 32)"
|
||||
printf 'CONDUCTOR_SECRET_KEY=%s\n' "$secret_key"
|
||||
printf 'CONDUCTOR_SESSION_KEY=%s\n' "$session_key"
|
||||
printf 'CONDUCTOR_IMAGE=gitea.skeletonworks.online/vwiebe/conductor\n'
|
||||
printf 'CONDUCTOR_VERSION=v1.0.0\n'
|
||||
printf 'CONDUCTOR_VERSION=v1.1.0\n'
|
||||
printf 'CONDUCTOR_BIND_ADDRESS=127.0.0.1\n'
|
||||
printf 'CONDUCTOR_PORT=8080\n'
|
||||
printf 'CONDUCTOR_MEM_LIMIT=1g\n'
|
||||
@ -101,7 +101,7 @@ The final command should return healthy JSON. If it fails, inspect:
|
||||
docker compose --env-file .env -f compose.production.yml logs --tail=200 conductor
|
||||
```
|
||||
|
||||
The v1.0.0 release is complete only after this pull succeeds without a Gitea username, password, or token. Check the Gitea `v1.0.0` Release entry for the published OCI index and AMD64 manifest digests. Do not substitute `latest` or an older release-candidate digest.
|
||||
The v1.1.0 release is complete only after this pull succeeds without a Gitea username, password, or token. Check the Gitea `v1.1.0` Release entry for the published OCI index and AMD64 manifest digests. Do not substitute `latest` or an older release-candidate digest.
|
||||
|
||||
## 4. Configure HTTPS
|
||||
|
||||
@ -195,7 +195,7 @@ Multiple origins are comma-separated. Wildcards, URL paths, credentials, and IP
|
||||
docker compose --env-file .env -f compose.production.yml up -d
|
||||
```
|
||||
|
||||
Read [Proxy Security](PROXY_SECURITY.md) before adding an exception.
|
||||
Read [Outbound proxy policy](OPERATIONS.md#outbound-proxy-policy) before adding an exception.
|
||||
|
||||
## Back up before real use
|
||||
|
||||
@ -238,3 +238,19 @@ docker compose --env-file .env -f compose.production.yml down
|
||||
Do not add `--volumes` unless permanent data deletion is intentional and a verified off-host backup exists. Retain `.env` with the backup set even after removing the container.
|
||||
|
||||
For common failures, see [Troubleshooting](TROUBLESHOOTING.md).
|
||||
|
||||
## Included-application support
|
||||
|
||||
The current source creates an empty installed-application registry in the existing SQLite database on startup. No additional database service or environment variable is required. This source change is not automatically present in an older published container image: deploy a build containing it before registering applications.
|
||||
|
||||
The Social Scheduler 0.4.0 kit includes this updated Conductor source and builds the integrated image inside Docker. Existing Skeleton Works setup-conductor.sh URL, volume and container settings work unchanged. Use the application's installer to update that installation; rerunning the base setup script can replace application-specific settings.
|
||||
|
||||
See [Included applications](INCLUDED_APPLICATIONS.md) for the installer contract and lifecycle responsibilities.
|
||||
|
||||
## Independently installed applications
|
||||
|
||||
Conductor v1.1.0 includes the default favicon, application-aware titles, publishing icons and application host API v1. Installers add verified browser packages and declared API routes without modifying this image. A fresh database has no seeded applications. See [Application packages](APP_PACKAGES.md).
|
||||
|
||||
The existing Conductor data volume also contains installed app files beneath /data/applications. Back up these files with the database and persistent keys. The updated backup/restore tools cover this state.
|
||||
|
||||
The Skeleton Works fresh setup script is updated separately in [SLICE12](../SLICE12.md). The supported existing-VM upgrade process is [SLICE13](../SLICE13.md). Do not rerun the older setup-conductor.sh against the combined Social Scheduler image as a substitute for that migration.
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
# Known Limitations for v1.0.0
|
||||
|
||||
- SQLite supports one Conductor instance with a persistent local volume; active/active replicas and PostgreSQL are not supported.
|
||||
- Local username/password authentication is the release provider. OIDC/SSO is planned for Slice 9.
|
||||
- Roles are global `admin` and `user`; per-application assignments and custom roles are not implemented.
|
||||
- Published visibility is application-wide (`public` or all authenticated users), not page-specific.
|
||||
- Page navigation is flat. Nested routes, path parameters, per-page permissions, and cross-page component bindings are not supported.
|
||||
- Runtime values are ephemeral and reset on full browser reload.
|
||||
- REST proxy authentication supports Anonymous, Basic, Bearer, API-key header, and API-key query. OAuth 2.0, mTLS, and custom auth are deferred.
|
||||
- Workflow orchestration, action graphs, loops, retries, and long-running jobs are out of scope.
|
||||
- Binding transforms and advanced expression languages are deferred.
|
||||
- The `v1.0.0` production image supports Linux/AMD64 only. ARM64 publication remains blocked on a native SQLite build/smoke runner.
|
||||
- The **Projects** navigation item is a placeholder. Create, save, update, and load projects from the Visual or JSON Editor toolbar; deletion remains API-only.
|
||||
- Component names are validated within a page, but project-wide unique names are recommended because template and binding paths do not include a page qualifier.
|
||||
- Page-variable scope records intended ownership, but v1.0.0 does not fully reject or prevent cross-page variable references. Keep each page-scoped variable on its declared page.
|
||||
- Backups are local archives by default; operators must arrange off-host copies and retention appropriate to their recovery objectives.
|
||||
@ -42,7 +42,20 @@ curl --fail https://conductor.example.com/api/health
|
||||
|
||||
The current SkeletonWorks setup script preserves the two keys in `.secrets`, but rewrites `CONDUCTOR_PROXY_INTERNAL_ORIGINS` to an empty value when it is rerun. Until that installer accepts and preserves an internal-origin setting, review and reapply the intended value after every setup or upgrade rerun before testing affected applications.
|
||||
|
||||
Do not casually rotate `CONDUCTOR_SECRET_KEY`; v1.0.0 has no online credential re-encryption procedure. Read [Secrets](SECRETS.md) and [Proxy Security](PROXY_SECURITY.md) before changing key or internal-origin policy.
|
||||
Do not casually rotate `CONDUCTOR_SECRET_KEY`; v1.1.0 has no online credential re-encryption procedure. Losing or changing it makes existing stored credentials unrecoverable. Changing `CONDUCTOR_SESSION_KEY` is safe when an intentional global sign-out is acceptable.
|
||||
|
||||
### Outbound proxy policy
|
||||
|
||||
Treat project URLs, DNS answers, redirects, upstream data, and errors as untrusted.
|
||||
|
||||
- Only `http` and `https` URLs without embedded credentials are accepted.
|
||||
- DNS must return only publicly routable addresses unless the exact hostname origin is operator-approved. Loopback, private, link-local, metadata, multicast, reserved, and mixed safe/unsafe answers fail closed.
|
||||
- Every redirect is revalidated. At most three are followed, and authentication or sensitive author headers are stripped when the origin changes.
|
||||
- Internal exceptions are comma-separated exact origins such as `https://api.internal.example:8443`. Schemes and effective ports must match; paths, wildcards, credentials, and IP literals are invalid.
|
||||
- Author request bodies are limited to 1 MiB, proxy API JSON to 2 MiB, responses to 5 MiB, and execution-history previews to 2 KiB. The fixed total timeout is 30 seconds.
|
||||
- Permit required private destinations narrowly and constrain container egress at the network layer when stronger isolation is needed.
|
||||
|
||||
Encryption at rest protects against disclosure of SQLite alone. It does not protect against compromise of the running backend, a hostile host administrator, memory inspection, a compromised REST destination, or disclosure of both the database and encryption key. Prefer header-based API keys over query-string keys when an upstream supports both.
|
||||
|
||||
## Backup
|
||||
|
||||
@ -218,3 +231,17 @@ The health response includes the packaged version when built with release metada
|
||||
## Recovery objectives
|
||||
|
||||
The default scripts retain seven daily archives, but retention is not a substitute for off-host storage. Recovery point and recovery time objectives are operator decisions. At least one periodic clean-host restore drill should be scheduled and recorded.
|
||||
|
||||
## Application icon storage (v1.1.0)
|
||||
|
||||
Conductor creates its app_icons table and adds a nullable icon_path column to publication metadata during normal database initialization. Repeating initialization is safe; older publications use the default icon. The canonical project-document schema remains 0.1.0.
|
||||
|
||||
Uploaded PNGs are decoded, re-encoded and stored in SQLite under a content hash. Requests are limited to 256 KiB, dimensions to square 16–512 pixels, and decompression is bounded. Matching images share one stored asset. Icons use immutable public URLs under /app-icons/; they are decorative assets rather than confidential documents.
|
||||
|
||||
Existing Conductor database backups cover both the icon choices and uploaded bytes. Keep normal database backup/restore procedures, persistent keys and matching application code. Installer-supplied static icons belong to that application's package and must be backed up with its code. Resetting an icon removes the publication's selection; it does not delete the shared image. Automatic removal of unused image assets is not implemented.
|
||||
|
||||
The v1.1.0 release adds independently installed apps. Fresh setup changes and the supported upgrade process are tracked in [SLICE12](../SLICE12.md) and [SLICE13](../SLICE13.md). Do not use a fresh setup run as an upgrade of the current combined Social Scheduler image.
|
||||
|
||||
## Installed application recovery in v1.1.0
|
||||
|
||||
The backup tools now write format conductor-backup-v2, including the exact application files referenced by the SQLite snapshot. The restore tool accepts v1 and v2. Old v1 backups have no independent app assets. Persistent keys decrypt both stored REST credentials and installed-app backend connections. Restore the matching app files as well as the database and keys; see [Application packages](APP_PACKAGES.md).
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
# Proxy Security and Execution-History Policy
|
||||
|
||||
## Threat model
|
||||
|
||||
The REST proxy accepts destinations and request templates from project authors. Treat those values, DNS answers, redirects, upstream headers, bodies, and errors as untrusted. The proxy must prevent access to the Conductor host and private infrastructure, credential forwarding to a different origin, request smuggling, unbounded memory use, and disclosure through responses, logs, or execution history.
|
||||
|
||||
Conductor enforces global `admin` and `user` roles. Only administrators can author or directly test REST actions; published execution resolves actions from server-owned snapshots. This policy does not isolate mutually hostile administrators, inspect malware, intercept upstream TLS, or protect an already-compromised backend process.
|
||||
|
||||
## Destination contract
|
||||
|
||||
- Only `http:` and `https:` URLs without embedded credentials are accepted.
|
||||
- Public destinations are allowed after every DNS result is classified. Loopback, unspecified, private, carrier-grade NAT, link-local, multicast, documentation, benchmark, reserved, and metadata-service address ranges are denied. A hostname is denied if any returned address is unsafe or DNS returns no usable address.
|
||||
- Connections use a validated address rather than performing an unchecked second DNS lookup.
|
||||
- Redirects are followed manually, resolved against the prior URL, and fully revalidated. At most three redirects are allowed. Authentication and author-supplied sensitive headers are removed whenever the redirect changes origin.
|
||||
- Internal exceptions are server-controlled exact origins in `CONDUCTOR_PROXY_INTERNAL_ORIGINS`, separated by commas (for example `https://api.internal.example:8443`). Schemes and effective ports must match. Paths, credentials, IP literals, and wildcards are invalid; subdomains are not inferred. Exceptions never enter project JSON.
|
||||
- Public hosts do not require an administrative hostname allowlist in the MVP. There are no wildcard rules. Any explicit non-default port is allowed only when its resolved addresses remain safe; deployments can constrain egress further at the network layer.
|
||||
|
||||
## Request and response controls
|
||||
|
||||
- Methods remain GET, POST, PUT, PATCH, and DELETE.
|
||||
- Author-supplied request headers are limited to `Accept`, `Accept-Language`, `Content-Type`, `If-Match`, `If-None-Match`, `Idempotency-Key`, and names beginning `X-`. `Authorization` and API-key headers may only be injected from server-side secrets. Cookie, proxy, forwarding, host, connection, transfer, upgrade, content-length, and other hop-by-hop headers are prohibited.
|
||||
- Request bodies are limited to 1 MiB after UTF-8 encoding. Proxy API JSON is limited to 2 MiB.
|
||||
- Response bodies are limited to 5 MiB while streaming. Execution-history previews are independently limited to 2 KiB.
|
||||
- The existing fixed 30-second total timeout is retained. There is no canonical per-action timeout.
|
||||
- Network, DNS, TLS, timeout, malformed-response, policy, and limit failures use stable safe codes and messages. They do not include stack traces, DNS answers, private addresses, or raw transport errors.
|
||||
|
||||
## Execution-history contract
|
||||
|
||||
Administrator-only execution history in SQLite stores the most recent 1,000 executions globally. Each record contains its timestamp, optional project identity supplied by the runtime, action ID/name, method, sanitized URL (query values and embedded credentials redacted), upstream status when available, duration, outcome, safe error code/message, response byte count, truncation flag, content type, and a redacted preview no larger than 2 KiB. Oldest records are deleted after insertion.
|
||||
|
||||
History persists across restart. `GET /api/executions` supports project, action, outcome, and status filters with a maximum page size of 100. `DELETE /api/executions` clears all history and returns no records. History never stores request or response headers, cookies, credential values, sensitive query values, stack traces, DNS results, resolved addresses, or secret-resolution details.
|
||||
@ -1,59 +0,0 @@
|
||||
# Release Construction and Publication
|
||||
|
||||
This procedure publishes the Conductor application release. The application version is `v1.0.0`; canonical project documents continue to use schema version `0.1.0`.
|
||||
|
||||
## Image identity and architecture
|
||||
|
||||
Each production image must correlate to one source revision and include OCI version, revision, creation-time, source, title, and description labels.
|
||||
|
||||
Release tags are:
|
||||
|
||||
- `vX.Y.Z`: immutable application release;
|
||||
- `git-<12-character-sha>`: immutable source-revision identity; and
|
||||
- `latest`: optional convenience pointer, never used in controlled installation instructions.
|
||||
|
||||
Conductor v1.0.0 supports `linux/amd64`. ARM64 is not a supported v1.0.0 artifact because its native SQLite build has not completed the required release smoke test.
|
||||
|
||||
## Build locally
|
||||
|
||||
From a clean release commit:
|
||||
|
||||
```bash
|
||||
scripts/release-image.sh --version v1.0.0 --platforms linux/amd64
|
||||
```
|
||||
|
||||
Run the production lifecycle tests against that image before publication.
|
||||
|
||||
## Publish the image
|
||||
|
||||
Use exactly one publication path. Do not allow an automated tag job and a manual push to race on the same immutable tag.
|
||||
|
||||
The tag-triggered Gitea workflow builds and pushes `v1.0.0` and `git-<sha>` image tags. It requires an available compatible runner and configured registry credentials. It publishes container images only; it does not create the human-facing Gitea Release entry.
|
||||
|
||||
If a controlled manual publication is required, first ensure no tag-triggered workflow can later rebuild or overwrite the tag. Authenticate with a scoped publisher token, check out the exact approved release commit, and run:
|
||||
|
||||
```bash
|
||||
docker login gitea.skeletonworks.online
|
||||
scripts/release-image.sh \
|
||||
--version v1.0.0 \
|
||||
--platforms linux/amd64 \
|
||||
--push
|
||||
```
|
||||
|
||||
Registry authentication is for publishers only. Consumers must be able to pull the final release without credentials.
|
||||
|
||||
## Release sequence
|
||||
|
||||
1. Prepare one clean release commit containing the code, documentation, production assets, and `1.0.0` package metadata.
|
||||
2. Run governance, documentation, schema, frontend, backend, browser, and production lifecycle suites.
|
||||
3. Confirm missing-key startup fails and configured startup, health, root UI, and published deep links pass.
|
||||
4. Confirm first-run administration, restart persistence, online backup, verified restore, upgrade, and rollback behavior.
|
||||
5. Obtain explicit release approval.
|
||||
6. Create the immutable annotated `v1.0.0` source tag on the approved commit and push it, using the automated image path only when its runner is available.
|
||||
7. Publish the `v1.0.0` and matching `git-<12-character-sha>` Linux/AMD64 images through the single selected publication path.
|
||||
8. From an unauthenticated environment, download the tagged source archive and pull the container image.
|
||||
9. Confirm the OCI `version` is `v1.0.0`, the OCI `revision` matches the tagged source commit, and the AMD64 image passes its smoke test.
|
||||
10. Create the Gitea Release entry for `v1.0.0` and record the source commit, archive checksums, OCI index and AMD64 manifest digests, Git-SHA image tag, supported architecture, anonymous verification result, limitations, and upgrade/rollback notes.
|
||||
11. Complete the post-publication checks in [Release Validation](RELEASE_VALIDATION.md) and record the external/manual acceptance result; publication is not complete while any final artifact check is blocked or failed.
|
||||
|
||||
If Gitea cannot provide anonymous image pulls, publish the same release image to a supported public registry, verify it anonymously, and update the installation guide and release entry before sign-off.
|
||||
@ -1,6 +1,6 @@
|
||||
# Conductor v1.0.0 Release Notes
|
||||
|
||||
Conductor v1.0.0 is the first production application release. It promotes the completed and accepted MVP feature set without changing the canonical project-document format.
|
||||
Conductor v1.0.0 is the first production application release. It delivers the initial supported feature set without changing the canonical project-document format.
|
||||
|
||||
## Release identity
|
||||
|
||||
@ -32,7 +32,7 @@ Application and project-schema versions are independent. Existing valid `schemaV
|
||||
- Application administration: [Administrator Guide](ADMIN_GUIDE.md)
|
||||
- Project authoring and use: [User Guide](USER_GUIDE.md)
|
||||
- Backup, restore, upgrade, and rollback: [Production Operations](OPERATIONS.md)
|
||||
- Security boundaries: [Secrets](SECRETS.md), [Proxy Security](PROXY_SECURITY.md), and [Authorization and Publishing Security](AUTHORIZATION_AND_PUBLISHING_SECURITY.md)
|
||||
- Security and recovery: [Administrator Guide](ADMIN_GUIDE.md#security-boundaries) and [Production Operations](OPERATIONS.md)
|
||||
|
||||
Conductor ships with no default credentials. Production browser access requires HTTPS because authentication and setup cookies are marked `Secure`.
|
||||
|
||||
@ -54,18 +54,16 @@ The project schema remains `0.1.0`, so the application-version promotion does no
|
||||
- Roles are global `admin` and `user`; custom roles and per-application assignments are not implemented.
|
||||
- Advanced workflows, transforms, expression languages, retries, and action graphs are deferred.
|
||||
|
||||
See [Known Limitations](KNOWN_LIMITATIONS.md) for the complete boundary.
|
||||
See [v1.0.0 limitations](USER_GUIDE.md#v100-limitations) for the complete boundary.
|
||||
|
||||
## Authoritative publication record
|
||||
## Published artifacts
|
||||
|
||||
The public Gitea Release entry for `v1.0.0` must record values generated during final publication rather than guessed in source documentation:
|
||||
| Artifact | Identity |
|
||||
|---|---|
|
||||
| Source commit | `498003f83d7b7bba2cd4387a48428b32b8b16667` |
|
||||
| Versioned image | `gitea.skeletonworks.online/vwiebe/conductor:v1.0.0` |
|
||||
| Source-correlated image | `gitea.skeletonworks.online/vwiebe/conductor:git-498003f83d7b` |
|
||||
| OCI index digest | `sha256:0c2db6bce31d81ad94df6a369eced714c6f291250789848803154985e5886a61` |
|
||||
| Linux/AMD64 manifest | `sha256:02899719e7992f56d8880dc73250ff4d4739665d8ff7912386e770b646f94126` |
|
||||
|
||||
- tagged source commit;
|
||||
- source archive checksums;
|
||||
- OCI index digest and Linux/AMD64 manifest digest;
|
||||
- matching `git-<12-character-sha>` image tag;
|
||||
- OCI version and revision correlation;
|
||||
- anonymous source-download and image-pull results; and
|
||||
- final publication date and release sign-off.
|
||||
|
||||
The release is not complete until the `v1.0.0` source tag, container image, and Gitea Release entry exist publicly and the post-publication gates in [Release Validation](RELEASE_VALIDATION.md) pass.
|
||||
The source tag and container are publicly accessible without registry credentials. The image records application version `v1.0.0`, the source commit above, SBOM/provenance attestations, and the non-root `conductor` runtime user.
|
||||
|
||||
15
docs/RELEASE_NOTES_v1.1.0.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Conductor v1.1.0
|
||||
|
||||
Conductor now hosts independently installed browser applications. Adding or updating an included app no longer requires patching Conductor source or rebuilding its image.
|
||||
|
||||
This release includes the finished home and Projects screens, the Included with Skeleton Works catalog, the approved default favicon, application-aware browser titles, optional published/installed app icons, and host API v1 for verified application packages.
|
||||
|
||||
A fresh installation has no seeded apps. Packages, icons, users, projects, publications and connections persist on the customer VM. API forwarding enforces declared routes, Conductor access/CSRF checks, fixed connection values and backend token boundaries. Social Scheduler 0.5.0 is the first package using this interface; a separate example demonstrates that the interface is generic.
|
||||
|
||||
Application version: 1.1.0. Canonical project schema: 0.1.0, unchanged. Platform: Linux/AMD64. Runtime: Node 24. Backup format: v2, adding installed app assets; the updated restore accepts old v1 backups as well.
|
||||
|
||||
Production dependency audit is clean after compatible runtime updates and a patched qs override for Express. The existing Create React App build toolchain still reports transitive development/build advisories. Its build tools are excluded from the production runtime; migration of that toolchain is a separate follow-up.
|
||||
|
||||
Read [Application packages](APP_PACKAGES.md) for the installer and API contract, and [Operations](OPERATIONS.md) for recovery. Current combined Conductor/Social Scheduler installations require the migration sequence in that guide. The old setup-conductor.sh is not an upgrade tool. SLICE12 and SLICE13 deliver the setup and existing-VM upgrade procedures.
|
||||
|
||||
Release publication details, exact commit and artifact digests are recorded in the release entry after verification. A local build or an attached archive alone does not prove image publication.
|
||||
@ -246,7 +246,8 @@ Optional canvas and display configuration.
|
||||
| `examples/project-definitions/valid-minimal.json` | Smallest valid project definition (one empty page). |
|
||||
| `examples/project-definitions/valid-full.json` | Full example: Concert Workflow Launcher with all features. |
|
||||
| `examples/project-definitions/valid-rest-actions.json` | Showcases all five authentication types across seven REST actions. No canvas components — validates the action model in isolation. |
|
||||
| `examples/project-definitions/valid-visual-rest-actions.json` | Slice 2 visual-authoring fixture covering anonymous GET/POST request fields. |
|
||||
| `examples/project-definitions/valid-visual-rest-actions.json` | Visual-authoring example covering anonymous GET/POST request fields. |
|
||||
| `examples/project-definitions/valid-conductor-tutorial.json` | Two-page tutorial project with GET/POST actions, templates, bindings, variables, and navigation. |
|
||||
| `examples/project-definitions/invalid-missing-required.json` | Intentionally invalid document showing schema errors. |
|
||||
|
||||
---
|
||||
@ -294,9 +295,30 @@ ajv validate \
|
||||
|
||||
### In Conductor
|
||||
|
||||
The backend applies this schema plus the semantic rules in [Project Validation](VALIDATION.md) before create, update, save, load acceptance, and publication. The JSON Editor uses the same validation contract before Apply. Validation failures leave the last valid project and stored row unchanged.
|
||||
The backend applies this schema plus semantic validation before create, update, save, load acceptance, and publication. The JSON Editor uses the same contract before Apply. Validation failures leave the last valid project, JSON draft, and stored row unchanged.
|
||||
|
||||
> This is documented here for reference. The validation endpoint itself is not part of Step 7.
|
||||
This release accepts exactly `schemaVersion: "0.1.0"`; it does not guess, coerce, or migrate another version. Validation preserves missing properties, explicit `null`, empty arrays, and empty objects rather than applying schema defaults.
|
||||
|
||||
Each diagnostic contains a stable code, JSON Pointer-style path, actionable message, and `error` or `warning` severity. Errors block Apply and persistence. Warnings identify readable compatibility shapes that are not executed, including `ComponentEvent.inputMap`, non-empty legacy `action.responseMapping`, component-level binding references, and `Binding.transform`.
|
||||
|
||||
Semantic checks include unique identities and names, valid references and event/action relationships, compatible binding sources and targets, variable default types, component-specific values, balanced templates, declared variables, and exact schema-version compatibility. Persistence repeats the validation assertion immediately before writing, so a rejected update cannot partially change a stored project.
|
||||
|
||||
## Runtime bindings
|
||||
|
||||
New mappings belong in top-level `project.bindings`; do not author the legacy `action.responseMapping` field.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "binding_inventory",
|
||||
"source": "actions.loadInventory.response.body.items",
|
||||
"target": "components.inventoryTable.rows",
|
||||
"trigger": "onSuccess"
|
||||
}
|
||||
```
|
||||
|
||||
Action response sources support `actions.<actionId>.response`, `.response.body`, and dot-separated fields below the body. Wildcards, filters, array-index syntax, transforms, and arbitrary expressions are not executed. Supported targets are compatible component `.value`, Dropdown `.options`, Table `.rows`, or a declared `variables.<name>`.
|
||||
|
||||
Action-response bindings use `onSuccess`. Table-selection bindings use source `components.<tableName>.selectedRow` with `onChange`; the selected row and resulting target value are runtime-only. Use project-wide unique component names because binding paths do not include page IDs.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
# Secrets and Authentication Security Model
|
||||
|
||||
## Stored credential policy
|
||||
|
||||
Conductor stores REST credential values in SQLite encrypted with AES-256-GCM. A persistent 32-byte master key is supplied as `CONDUCTOR_SECRET_KEY`, encoded as base64 or 64 hexadecimal characters. The key is never stored in SQLite, project JSON, frontend state, browser responses, execution history, or source control.
|
||||
|
||||
Canonical project actions identify credentials by an opaque `secretReferenceId`. After authorization, the backend resolves the credential while constructing the upstream request. Destination and redirect policy must pass before that request is transmitted; the browser never receives the credential value.
|
||||
|
||||
## Access boundaries
|
||||
|
||||
- Administrators can create, replace, list, and delete stored-credential metadata through **Actions & Bindings → Secrets**.
|
||||
- Administrators cannot retrieve an existing credential value; replacement requires the complete new value.
|
||||
- Users and anonymous visitors cannot list secret metadata or access authoring APIs.
|
||||
- Public applications may contain only anonymous actions and cannot use stored credentials.
|
||||
- Restricted published applications may use stored credentials, but the browser submits only runtime values to an action already fixed in the server-owned snapshot.
|
||||
- Host operators with both the database and encryption key are inside the trusted recovery boundary and could decrypt credentials.
|
||||
|
||||
Deletion is blocked while a saved project action refers to the credential. Secret names and authentication types are non-secret metadata, but should still avoid embedding customer or credential values.
|
||||
|
||||
## Stored credential shapes
|
||||
|
||||
- `basicAuth`: username and password
|
||||
- `bearerToken`: token
|
||||
- `apiKeyHeader`: header name and API-key value
|
||||
- `apiKeyQueryParameter`: query-parameter name and API-key value
|
||||
|
||||
List and lookup responses contain only ID, name, authentication type, and timestamps.
|
||||
|
||||
## Key and session lifecycle
|
||||
|
||||
- Generate `CONDUCTOR_SECRET_KEY` once with `openssl rand -hex 32` and preserve it across restarts, upgrades, and restores.
|
||||
- Generate `CONDUCTOR_SESSION_KEY` independently with `openssl rand -hex 32`. It protects sessions; changing it signs everyone out.
|
||||
- Production startup fails if the encryption key does not encode exactly 32 bytes or the session key contains fewer than 32 bytes.
|
||||
- Losing or changing `CONDUCTOR_SECRET_KEY` makes existing stored credentials unrecoverable.
|
||||
- Online key rotation and credential re-encryption are not implemented in v1.0.0.
|
||||
- Never commit `.env`, paste key values into tickets, or include them in terminal recordings and screenshots.
|
||||
|
||||
The public installation procedure creates `.env` with mode `0600`. The backup tool includes that file because the database alone is not a complete recovery set. Treat every backup archive as a credential-bearing secret, keep an off-host copy, and restore the archive as one unit. Do not combine a database with keys from another installation.
|
||||
|
||||
## Threat assumptions
|
||||
|
||||
Encryption at rest protects against disclosure of SQLite alone. It does not protect against compromise of the running backend process, a hostile host administrator, memory inspection, a compromised REST destination, or an attacker who obtains both the database and master key.
|
||||
|
||||
Authentication injection, URL sanitization, and error/history redaction occur server-side. Query-string API keys receive the same encrypted storage but remain inherently more likely to be exposed by an upstream service; prefer header-based authentication when the destination supports it.
|
||||
|
||||
See [Authorization and Publishing Security](AUTHORIZATION_AND_PUBLISHING_SECURITY.md), [Proxy Security](PROXY_SECURITY.md), and [Production Operations](OPERATIONS.md) for the surrounding trust and recovery model.
|
||||
@ -43,7 +43,7 @@ The reverse proxy must send all non-API paths to Conductor rather than serving s
|
||||
|
||||
## REST request is blocked
|
||||
|
||||
Read the safe error code and `docs/PROXY_SECURITY.md`. Private/internal destinations are denied unless their exact origin is configured in `CONDUCTOR_PROXY_INTERNAL_ORIGINS`. Do not add broad exceptions.
|
||||
Read the safe error code and [Outbound proxy policy](OPERATIONS.md#outbound-proxy-policy). Private/internal destinations are denied unless their exact origin is configured in `CONDUCTOR_PROXY_INTERNAL_ORIGINS`. Do not add broad exceptions.
|
||||
|
||||
## Browser shows old controls after upgrade
|
||||
|
||||
|
||||
195
docs/TUTORIAL.md
Normal file
@ -0,0 +1,195 @@
|
||||
# Conductor Tutorial: Build and Publish a REST Application
|
||||
|
||||
This tutorial uses a ready-made two-page project to introduce Conductor's main features. You will import it, run REST actions, inspect bindings, modify the visual design, save it, and publish it as a standalone application.
|
||||
|
||||
Allow about 25 minutes. You need an administrator account because project authoring and publishing are administrator functions.
|
||||
|
||||
## What you will build
|
||||
|
||||
The companion [Post Workshop project](../examples/project-definitions/valid-conductor-tutorial.json) uses the public JSONPlaceholder training API. It contains:
|
||||
|
||||
- two pages with automatic navigation and button navigation;
|
||||
- Labels, Text Input, Text Area, Buttons, Status Panel, Table, and JSON Viewer components;
|
||||
- a global typed variable;
|
||||
- GET and POST REST actions;
|
||||
- component and variable request templates;
|
||||
- a page `onLoad` action and Button `onClick` actions;
|
||||
- action-response bindings and a Table-selection binding; and
|
||||
- public-publishing-compatible anonymous actions.
|
||||
|
||||
JSONPlaceholder simulates writes but does not save them. Do not enter private, confidential, or production information; tutorial field values are sent to a public third-party service. Its behavior is documented in the [JSONPlaceholder guide](https://jsonplaceholder.typicode.com/guide/).
|
||||
|
||||
## 1. Import the project
|
||||
|
||||
1. Download or open `examples/project-definitions/valid-conductor-tutorial.json` from the Conductor source bundle.
|
||||
2. Sign in to Conductor as an administrator.
|
||||
3. Open **JSON Editor**.
|
||||
4. Select all existing JSON and replace it with the complete contents of `valid-conductor-tutorial.json`.
|
||||
5. Select **Apply**.
|
||||
6. Confirm the validation summary reports no errors, then open **Visual Editor**.
|
||||
|
||||
Applying JSON changes the in-memory project; it does not save it to the database. If Apply fails, confirm you copied the entire file and that `schemaVersion` remains `0.1.0`.
|
||||
|
||||
## 2. Tour the Visual Editor
|
||||
|
||||
The **Create a Post** page should be selected. Notice these parts of the workspace:
|
||||
|
||||
- The project command area shows the project name and New, Save, Load, and Preview controls.
|
||||
- The page ribbon switches pages and changes page names, slugs, order, navigation visibility, and lifecycle events.
|
||||
- The component palette adds controls to the active page.
|
||||
- The canvas shows the application layout.
|
||||
- **Selection** edits the selected component's identity, position, size, properties, appearance, and events.
|
||||
|
||||
Select **Post title**, **Post body**, **Create post**, and **API response** in turn. Observe how Text Input, Text Area, Button, and JSON Viewer expose different properties.
|
||||
|
||||
Try one harmless visual change:
|
||||
|
||||
1. Select the heading.
|
||||
2. Change its displayed text to `My REST Post Workshop`.
|
||||
3. Move or resize it on the canvas.
|
||||
|
||||
The dot beside the project name indicates an unsaved canonical change.
|
||||
|
||||
## 3. Run the POST action
|
||||
|
||||
1. Open **Preview**.
|
||||
2. Change the post title and body if desired, using only non-sensitive tutorial text.
|
||||
3. Select **Create post**.
|
||||
|
||||
The Status Panel should show created ID `101`, and the JSON Viewer should display the simulated response. This single interaction demonstrates:
|
||||
|
||||
```text
|
||||
Button onClick
|
||||
→ Create simulated post action
|
||||
→ component and variable template interpolation
|
||||
→ server-side POST request
|
||||
→ response bindings
|
||||
→ Status Panel and JSON Viewer runtime updates
|
||||
```
|
||||
|
||||
Return to **JSON Editor** and notice that your Preview inputs, response, and ID were not written into the project. Preview state is intentionally ephemeral.
|
||||
|
||||
## 4. Inspect the action and templates
|
||||
|
||||
Open **Actions & Bindings**, expand **REST Actions**, and open **Create simulated post**.
|
||||
|
||||
The action sends `POST https://jsonplaceholder.typicode.com/posts` with a JSON content type. Its request body uses these values:
|
||||
|
||||
```text
|
||||
{{components.postTitle.value}}
|
||||
{{components.postBody.value}}
|
||||
{{variables.defaultUserId}}
|
||||
```
|
||||
|
||||
Component values come from the page that invoked the action. `defaultUserId` is a global number variable with default value `1`.
|
||||
|
||||
Use **Test Action** only to understand its limitation: action testing has no interactive Preview component state. Template-driven actions should be exercised from Preview, where the components have runtime values.
|
||||
|
||||
## 5. Inspect the response bindings
|
||||
|
||||
In **Actions & Bindings**, expand **Response Bindings**. The POST action has two `onSuccess` bindings:
|
||||
|
||||
| Source | Target | Result |
|
||||
|---|---|---|
|
||||
| `actions.action_create_post.response.body.id` | `components.createdPostId.value` | Shows the returned ID in the Status Panel. |
|
||||
| `actions.action_create_post.response.body` | `components.createResponse.value` | Shows the complete body in the JSON Viewer. |
|
||||
|
||||
Bindings move response data into runtime component properties. They do not modify authored defaults or save API responses in project JSON.
|
||||
|
||||
## 6. Explore pages, page events, and tables
|
||||
|
||||
1. Return to **Preview**.
|
||||
2. Select **Browse sample posts** or use the automatic page navigation.
|
||||
3. Wait for the table to populate.
|
||||
4. Select any table row.
|
||||
|
||||
Opening **Browse Posts** for the first time triggers its page `onLoad` event, which runs **Load sample posts**. Its response body is bound to `components.postsTable.rows`.
|
||||
|
||||
Selecting a row triggers a different kind of binding:
|
||||
|
||||
```text
|
||||
components.postsTable.selectedRow
|
||||
→ components.selectedPost.value
|
||||
→ onChange
|
||||
```
|
||||
|
||||
The JSON Viewer displays the selected record without making another REST request. Select **Refresh posts** to run the GET action explicitly, then use **Back to create** to exercise button-based page navigation.
|
||||
|
||||
## 7. Change a variable
|
||||
|
||||
Open **Actions & Bindings**, expand **Variables**, and inspect `defaultUserId`.
|
||||
|
||||
This global number is used by both actions:
|
||||
|
||||
- the POST body assigns the simulated post to that user; and
|
||||
- the GET query filters the table by that user.
|
||||
|
||||
Change its default from `1` to `2`, return to Preview, and reload or re-enter the Browse page. The table should now show posts for user 2. This illustrates how one declared value can drive several request fields.
|
||||
|
||||
## 8. Add a component
|
||||
|
||||
Return to **Visual Editor** and add one component of your choice. For example:
|
||||
|
||||
1. Select the **Create a Post** page.
|
||||
2. Add a **Checkbox**.
|
||||
3. Name it `reviewedTutorial`.
|
||||
4. Set its label to `I reviewed this tutorial`.
|
||||
5. Move it beneath the navigation button.
|
||||
|
||||
Preview the application and interact with it. The checkbox value exists only during that loaded Preview session unless you deliberately use it in a request template.
|
||||
|
||||
## 9. See validation protect the project
|
||||
|
||||
1. Open **JSON Editor**.
|
||||
2. Find the Create post Button's `actionId` value: `action_create_post`.
|
||||
3. Temporarily change it to `missing_action`.
|
||||
4. Select **Apply**.
|
||||
|
||||
Conductor should report a semantic reference error and preserve the last valid project. Correct the value or select **Revert**. This is the same validation gate used by Apply, Save, Update, Load, and Publish.
|
||||
|
||||
## 10. Save and reload
|
||||
|
||||
1. Return to **Visual Editor**.
|
||||
2. Select **Save**.
|
||||
3. If prompted, keep the name `Conductor Tutorial — Post Workshop` or choose a unique tutorial name.
|
||||
4. Make another small edit and select **Update**.
|
||||
5. Select **New**, confirm the discard prompt only if no unsaved work remains, then select **Load** and reopen the tutorial project.
|
||||
|
||||
The authored pages, components, actions, bindings, and variable should return. Preview-only values and API responses should not.
|
||||
|
||||
## 11. Review history
|
||||
|
||||
Open **Execution History**. The GET and POST attempts should appear with method, sanitized URL, outcome, duration, status, size, and a bounded response preview.
|
||||
|
||||
History is for troubleshooting. It does not store request or response headers, cookies, credentials, or sensitive query values. Use **Clear history** only when you intentionally want to remove all retained execution records.
|
||||
|
||||
## 12. Publish the application
|
||||
|
||||
Because both tutorial actions use anonymous authentication, the project can be published publicly.
|
||||
|
||||
1. Confirm the project is saved and valid.
|
||||
2. Open **Publishing**.
|
||||
3. Select the tutorial project.
|
||||
4. Enter a display name and a unique slug such as `post-workshop`.
|
||||
5. Choose **Public** or **Restricted to users**.
|
||||
6. Select **Publish**, then **Open app**.
|
||||
7. Test both pages and refresh the `/apps/<slug>/browse-posts` deep link directly.
|
||||
|
||||
Saving the source project does not update the live snapshot. Make a visible source edit, save it, and use **Republish** to learn that lifecycle. **Unpublish** removes public access without deleting the source project.
|
||||
|
||||
## 13. Where credentials fit
|
||||
|
||||
This tutorial deliberately uses anonymous actions. For a real protected API:
|
||||
|
||||
1. An administrator creates an encrypted credential under **Actions & Bindings → Secrets**.
|
||||
2. The action selects its authentication type and the compatible opaque secret reference.
|
||||
3. Conductor injects the credential server-side; it never places the value in project JSON or the published browser application.
|
||||
|
||||
Public applications cannot contain credential-backed actions. Choose restricted visibility for such an application. Never paste credentials into URLs, headers, templates, tutorial data, or JSON.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Use the [User Guide](USER_GUIDE.md) as the feature reference.
|
||||
- Use the [Project Schema](SCHEMA.md) when editing JSON directly.
|
||||
- Use the [Administrator Guide](ADMIN_GUIDE.md) for accounts, credentials, publishing, and security boundaries.
|
||||
- Use [Troubleshooting](TROUBLESHOOTING.md) if a request or published route fails.
|
||||
@ -34,7 +34,7 @@ Administrators see these areas in the sidebar:
|
||||
- **Publishing** creates and updates runnable application snapshots.
|
||||
- **Users** manages local accounts and sessions.
|
||||
|
||||
The **Projects** sidebar item is a placeholder in v1.0.0. Create, save, and load projects from the toolbar in **Visual Editor** or **JSON Editor**.
|
||||
Use **Projects** to create, find, open, preview, rename or delete saved builder projects. The **Publish** action selects that project on the Publishing screen. The Visual Editor and JSON Editor toolbar still provides Save, Update and Load.
|
||||
|
||||
## Build a first application
|
||||
|
||||
@ -119,17 +119,17 @@ Use the editor's template suggestions to place runtime values in supported reque
|
||||
{{variables.environment}}
|
||||
```
|
||||
|
||||
Component values resolve from the page that invoked the action. Variables resolve by name. Treat a page-scoped variable as belonging only to its declared page; v1.0.0 does not yet enforce that ownership when resolving every runtime reference. Do not put passwords, tokens, or API keys into templates, headers, URLs, or project JSON; select a compatible stored secret instead.
|
||||
Component values resolve from the page that invoked the action. Variables resolve by name. Treat a page-scoped variable as belonging only to its declared page; v1.1.0 does not yet enforce that ownership when resolving every runtime reference. Do not put passwords, tokens, or API keys into templates, headers, URLs, or project JSON; select a compatible stored secret instead.
|
||||
|
||||
**Test Action** runs the current action configuration through the backend proxy. It is useful for static requests. It does not inject interactive Preview values, so exercise template-driven requests from **Preview**.
|
||||
|
||||
Conductor blocks private and internal destinations unless the server operator has approved the exact origin. See [Proxy Security](PROXY_SECURITY.md) for allowed headers, limits, redirects, and error behavior.
|
||||
Conductor blocks private and internal destinations unless the server operator has approved the exact origin. Allowed author headers are `Accept`, `Accept-Language`, `Content-Type`, `If-Match`, `If-None-Match`, `Idempotency-Key`, and names beginning with `X-`. Requests have a fixed 30-second timeout, request bodies are limited to 1 MiB, and responses are limited to 5 MiB. Redirects are revalidated and sensitive headers are removed when the origin changes. Ask the host operator to review any required internal-origin exception.
|
||||
|
||||
## Stored credentials
|
||||
|
||||
Administrators create credentials in **Actions & Bindings → Secrets**. Supported shapes are Basic authentication, Bearer token, API-key header, and API-key query parameter.
|
||||
|
||||
Credential values are encrypted server-side and are never displayed again after submission. Projects store only opaque references. Replacing a value requires entering the new credential fields once; deleting a credential is blocked while a saved project action references it. More detail is in the [Administrator Guide](ADMIN_GUIDE.md#manage-stored-credentials) and [Secrets Security Model](SECRETS.md).
|
||||
Credential values are encrypted server-side and are never displayed again after submission. Projects store only opaque references. Replacing a value requires entering the new credential fields once; deleting a credential is blocked while a saved project action references it. See [Manage stored credentials](ADMIN_GUIDE.md#manage-stored-credentials) for the complete lifecycle.
|
||||
|
||||
## Variables and response bindings
|
||||
|
||||
@ -152,7 +152,7 @@ Supported targets are:
|
||||
| `components.<name>.rows` | Table; response must be an array of objects |
|
||||
| `variables.<name>` | A declared runtime variable; the response value is assigned without coercion or a runtime type check |
|
||||
|
||||
Use `onSuccess` for action-response bindings. Table selection uses `components.<table-name>.selectedRow` with `onChange`. The guided response-binding editor creates action-response bindings only; use the **JSON Editor** to add a Table-selection binding, and keep the Table name project-wide unique. Wildcards, filters, transforms, action chains, and arbitrary reactive graphs are not executed in v1.0.0.
|
||||
Use `onSuccess` for action-response bindings. Table selection uses `components.<table-name>.selectedRow` with `onChange`. The guided response-binding editor creates action-response bindings only; use the **JSON Editor** to add a Table-selection binding, and keep the Table name project-wide unique. Wildcards, filters, transforms, action chains, and arbitrary reactive graphs are not executed in v1.1.0.
|
||||
|
||||
## Preview and validation
|
||||
|
||||
@ -189,4 +189,30 @@ Saving the source project does not change the live snapshot. Use **Republish** t
|
||||
- If an old interface remains after an upgrade, perform a hard refresh.
|
||||
- If Apply, Save, or Publish fails, correct the validation path shown in the interface; the last valid project remains intact.
|
||||
|
||||
See [Troubleshooting](TROUBLESHOOTING.md) for remedies and [Known Limitations](KNOWN_LIMITATIONS.md) for the full v1.0.0 boundary.
|
||||
See [Troubleshooting](TROUBLESHOOTING.md) for symptom-based remedies.
|
||||
|
||||
## v1.1.0 limitations
|
||||
|
||||
- The production image supports Linux/AMD64 only.
|
||||
- SQLite supports one Conductor instance; active/active replicas and PostgreSQL are not supported.
|
||||
- Authentication uses local accounts. Roles are global `admin` and `user`; there are no groups, custom roles, or per-application assignments.
|
||||
- Published visibility applies to the whole application, not individual pages.
|
||||
- Page navigation is flat. Nested routes, path parameters, per-page permissions, and cross-page component bindings are not supported.
|
||||
- Runtime values reset on full browser reload.
|
||||
- REST authentication supports Anonymous, Basic, Bearer, API-key header, and API-key query. OAuth 2.0, mTLS, and custom authentication are not supported.
|
||||
- Workflow graphs, conditions, loops, retries, long-running jobs, binding transforms, wildcards, filters, and expression languages are not executed.
|
||||
- A project with an active publication must be unpublished before it can be deleted.
|
||||
- Component names should be project-wide unique because template and binding paths do not include a page qualifier.
|
||||
- Page-variable ownership is not fully enforced across every runtime reference; keep page-scoped variables on their declared page.
|
||||
|
||||
## Installed applications
|
||||
|
||||
The **Included with Skeleton Works** section contains applications installed on your VM. Ordinary users see eligible applications in their application catalog. Administrators can also open them from Publishing and the home page. These entries appear after successful installation and are separate from saved builder projects. Application-specific configuration and permissions still apply.
|
||||
|
||||
## Recognizing applications in browser tabs (v1.1.0)
|
||||
|
||||
Conductor v1.1.0 uses **Application name · Conductor** for an open application. For example, Social Scheduler appears as **Social Scheduler · Conductor**. Core screens use names such as **Projects · Conductor**; the home page remains **Conductor**.
|
||||
|
||||
The blue C-and-baton symbol is the default browser icon. An application can have its own icon, which also appears on its application card. If no custom icon is available, Conductor uses the default. Signing out returns protected screens to the Conductor sign-in title and icon.
|
||||
|
||||
An administrator can change a published application's icon from Publishing. Refresh an already-open application tab after an icon change. Your browser may retain an older name or image on a bookmark you saved earlier; rename or recreate that bookmark if needed.
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
# Project Validation
|
||||
|
||||
Conductor uses `shared/schemas/conductor-project.schema.json` plus backend semantic validation as the canonical project-document gate. The same validator is used by JSON Apply, create, update, save, stored-project load checks, and frontend diagnostics.
|
||||
|
||||
## Compatibility policy
|
||||
|
||||
- This release accepts exactly `schemaVersion: "0.1.0"`.
|
||||
- Other versions fail with `SCHEMA_VERSION_UNSUPPORTED`; Conductor does not guess, coerce, or migrate them.
|
||||
- Invalid stored JSON is left untouched and returned as `STORED_PROJECT_INVALID` when loaded. There is no automatic project-schema migration.
|
||||
- Missing properties remain missing and explicit JSON values—including `null`, empty arrays, and empty objects—are preserved. Validation does not apply schema defaults or mutate documents.
|
||||
|
||||
## Result contract
|
||||
|
||||
Validation returns `valid`, `errors`, and `warnings`. Every issue has a stable `code`, JSON Pointer-style `path`, actionable `message`, and `severity`.
|
||||
|
||||
Errors block Apply and persistence. Warnings identify compatibility-only shapes that remain readable but are not executed: `ComponentEvent.inputMap`, non-empty deprecated `action.responseMapping`, component-level binding references, and `Binding.transform`.
|
||||
|
||||
Project create/update failures use:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "PROJECT_VALIDATION_FAILED",
|
||||
"message": "Project document is invalid.",
|
||||
"errors": [],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
No database write occurs until parsing, schema validation, and semantic validation all succeed. The database access layer repeats the validation assertion so future callers cannot bypass the route gate.
|
||||
|
||||
## Semantic rules
|
||||
|
||||
Blocking checks cover:
|
||||
|
||||
- unique page, action, and binding identities/names; project-wide component IDs; and component names within each page;
|
||||
- supported page/component events and existing action references;
|
||||
- existing action, component, variable, binding, and default-page references;
|
||||
- runtime-supported action-response paths and Table-selection sources, triggers, and component target properties;
|
||||
- variable default-value type compatibility, while preserving explicit `null`;
|
||||
- component-specific options, configured values, Table columns, and value types;
|
||||
- balanced templates, supported component value templates, and declared variables;
|
||||
- exact schema-version compatibility.
|
||||
|
||||
Path parameters follow one contract: frontend runtime interpolation resolves `{{components.<name>.value}}` and `{{variables.<name>}}` inside `pathParameters` values; the backend then substitutes those rendered values into bare URL placeholders such as `{{itemId}}`.
|
||||
|
||||
Table selection uses the canonical component binding shape `components.<tableName>.selectedRow` → `components.<displayName>.value` with trigger `onChange`. Use a project-wide unique Table name: v1.0.0 validation does not diagnose same-named source Tables on different pages. The target must resolve uniquely and support runtime `value`. Selecting a row updates only ephemeral Preview state; neither the selected row nor the display value is persisted.
|
||||
|
||||
## UI and recovery
|
||||
|
||||
Visual Editor, Actions & Bindings, JSON Editor, and Preview display the shared result. A validation/network failure leaves canonical state and JSON Editor draft text unchanged. Save remains dirty after failure. Loading an invalid stored document leaves the currently open project intact. New and Load ask for confirmation before discarding unsaved edits.
|
||||
|
||||
Project deletion remains backend-only for v1.0.0. A dedicated Projects screen and deletion control are deferred because project browsing is not yet an implemented MVP surface; the existing API continues to require an explicit project ID.
|
||||
@ -1,82 +0,0 @@
|
||||
# Canonical Response Mapping and Runtime Model
|
||||
|
||||
## Ownership
|
||||
|
||||
New response mappings are top-level `project.bindings`. The legacy `action.responseMapping` field remains schema-compatible but is not executed and must not be authored by new UI/examples.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "binding_inventory",
|
||||
"source": "actions.loadInventory.response.body.items",
|
||||
"target": "components.inventoryTable.rows",
|
||||
"trigger": "onSuccess"
|
||||
}
|
||||
```
|
||||
|
||||
## Action execution
|
||||
|
||||
Actions are project-level and reusable. A Button `onClick` or page lifecycle event selects an `actionId`. A Button event may instead contain `navigateToPageId`; navigation is not represented as a REST action. Table row selection drives `onChange` data bindings rather than executing an action directly.
|
||||
|
||||
Before execution, request fields interpolate:
|
||||
|
||||
- `{{components.<name>.value}}` against components on the invoking page only.
|
||||
- `{{variables.<name>}}` against the project's runtime-variable map. Page scope records intended ownership, but v1.0.0 does not enforce that ownership across every runtime reference; authors must keep page variables on their declared page.
|
||||
|
||||
Credential-backed authentication is resolved by the backend through opaque `secretReferenceId`. Browser clients never supply authoritative URLs, authentication modes, actions, or secret values for published execution.
|
||||
|
||||
## Response source paths
|
||||
|
||||
Canonical action sources begin with:
|
||||
|
||||
```text
|
||||
actions.<actionId>.response
|
||||
actions.<actionId>.response.body
|
||||
actions.<actionId>.response.body.<dot-separated-field>
|
||||
```
|
||||
|
||||
The current resolver supports ordinary dot-separated object traversal. JSONPath filters, wildcards, quoted dotted keys, and transform expressions are deferred.
|
||||
|
||||
## Targets
|
||||
|
||||
Supported action-response targets are validated against component type/property compatibility:
|
||||
|
||||
- `components.<name>.value`
|
||||
- `components.<dropdownName>.options`
|
||||
- `components.<tableName>.rows`
|
||||
- `variables.<name>`
|
||||
|
||||
Component names are validated within a page, but binding paths do not contain a page ID. The current runtime requires a component target name to resolve unambiguously across the project and skips a binding when several pages reuse that name. A same-named Table on multiple pages can also activate the same selection binding. Use project-wide unique component names. Keep page-variable targets on their owning page; cross-page response flows are not a supported authoring pattern, although that variable-scope boundary is not fully diagnosed or enforced in v1.0.0.
|
||||
|
||||
## Triggers
|
||||
|
||||
- `onSuccess` is the canonical trigger for an action response and runs only when `ProxyResponse.ok` is true.
|
||||
- Legacy action-response `onClick` is accepted for compatibility and should be migrated to `onSuccess`.
|
||||
- Table `components.<name>.selectedRow` bindings use `onChange`.
|
||||
- `onError`, action chaining, and arbitrary reactive component graphs are deferred.
|
||||
|
||||
## Runtime state
|
||||
|
||||
Configured defaults remain in canonical project JSON. Preview/published component, action, and variable values live in ephemeral runtime state and never mutate the canonical document. Global and page values survive in-session page navigation and reset on full reload.
|
||||
|
||||
Actions expose loading, success, upstream failure, network/policy failure, and mapping failure states. Mapped components receive loading/error state while requests run. When a successful action has no mapping, the triggering control surfaces the response for operator feedback.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- Page `onLoad` runs on the first entry to that page during one loaded runtime session.
|
||||
- If no `onLoad` event is configured, `onEnter` is the first-entry fallback.
|
||||
- Page `onEnter` runs when returning to a previously visited page.
|
||||
- Reopening/reloading the runtime begins a fresh session.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Validation rejects or warns about:
|
||||
|
||||
- dangling action, binding, page, component, variable, and secret references;
|
||||
- unsupported trigger/source/target combinations;
|
||||
- duplicate page/component identities in their required scopes;
|
||||
- malformed templates and unsupported component template properties;
|
||||
- a page-scoped variable whose declared owner page does not exist;
|
||||
- incompatible response target properties;
|
||||
- deprecated `action.responseMapping` and inert `inputMap` compatibility fields.
|
||||
|
||||
The backend performs schema and semantic validation before persistence/publication. The frontend shows the same actionable paths without replacing the last valid project state.
|
||||
6
examples/installed-app/app.js
Normal file
@ -0,0 +1,6 @@
|
||||
export function mount(element, context) {
|
||||
const title=document.createElement("h1");title.textContent="Example Application";
|
||||
const message=document.createElement("p");message.textContent="Opened by "+context.user.displayName+". This application was installed without rebuilding Conductor.";
|
||||
element.style.padding="32px";element.append(title,message);
|
||||
return ()=>element.replaceChildren();
|
||||
}
|
||||
19
examples/installed-app/conductor-app.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"packageVersion": 1,
|
||||
"hostApiVersion": 1,
|
||||
"application": {
|
||||
"schemaVersion": 1,
|
||||
"id": "example-application",
|
||||
"name": "Example Application",
|
||||
"description": "A minimal independent app package for host verification.",
|
||||
"version": "1.0.0",
|
||||
"launchPath": "/example-application",
|
||||
"audience": "all-users"
|
||||
},
|
||||
"entry": {
|
||||
"script": "app.js"
|
||||
},
|
||||
"files": {
|
||||
"app.js": "bb852b3353e4d122485af4b03d64965f536581c2bee1507ff78340a5661e107d"
|
||||
}
|
||||
}
|
||||
276
examples/project-definitions/valid-conductor-tutorial.json
Normal file
@ -0,0 +1,276 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_conductor_tutorial",
|
||||
"name": "Conductor Tutorial — Post Workshop",
|
||||
"description": "A safe two-page tutorial using JSONPlaceholder's simulated REST API.",
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_create_post",
|
||||
"name": "Create a Post",
|
||||
"slug": "create-post",
|
||||
"showInNavigation": true,
|
||||
"description": "Submit a simulated post and inspect the response.",
|
||||
"order": 0,
|
||||
"components": [
|
||||
{
|
||||
"id": "cmp_create_title",
|
||||
"type": "Label",
|
||||
"name": "createHeading",
|
||||
"position": { "x": 24, "y": 20 },
|
||||
"size": { "width": 600, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Create a simulated post",
|
||||
"value": "Create a simulated post",
|
||||
"visible": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cmp_post_title",
|
||||
"type": "TextInput",
|
||||
"name": "postTitle",
|
||||
"position": { "x": 24, "y": 80 },
|
||||
"size": { "width": 500, "height": 64 },
|
||||
"properties": {
|
||||
"label": "Post title",
|
||||
"placeholder": "What did you learn?",
|
||||
"defaultValue": "My first Conductor application",
|
||||
"required": true,
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cmp_post_body",
|
||||
"type": "TextArea",
|
||||
"name": "postBody",
|
||||
"position": { "x": 24, "y": 160 },
|
||||
"size": { "width": 500, "height": 130 },
|
||||
"properties": {
|
||||
"label": "Post body",
|
||||
"placeholder": "Write a short message",
|
||||
"defaultValue": "I connected components to a REST action and mapped its response.",
|
||||
"required": true,
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cmp_submit_post",
|
||||
"type": "Button",
|
||||
"name": "submitPost",
|
||||
"position": { "x": 24, "y": 312 },
|
||||
"size": { "width": 180, "height": 44 },
|
||||
"properties": {
|
||||
"label": "Create post",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{ "event": "onClick", "actionId": "action_create_post" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_created_id",
|
||||
"type": "StatusPanel",
|
||||
"name": "createdPostId",
|
||||
"position": { "x": 224, "y": 312 },
|
||||
"size": { "width": 300, "height": 64 },
|
||||
"properties": {
|
||||
"label": "Created post ID",
|
||||
"value": "Not submitted",
|
||||
"visible": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cmp_create_response",
|
||||
"type": "JsonViewer",
|
||||
"name": "createResponse",
|
||||
"position": { "x": 560, "y": 80 },
|
||||
"size": { "width": 560, "height": 296 },
|
||||
"properties": {
|
||||
"label": "API response",
|
||||
"defaultValue": null,
|
||||
"visible": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cmp_go_browse",
|
||||
"type": "Button",
|
||||
"name": "goToBrowse",
|
||||
"position": { "x": 24, "y": 410 },
|
||||
"size": { "width": 220, "height": 44 },
|
||||
"properties": {
|
||||
"label": "Browse sample posts",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{ "event": "onClick", "navigateToPageId": "page_browse_posts" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": []
|
||||
},
|
||||
{
|
||||
"id": "page_browse_posts",
|
||||
"name": "Browse Posts",
|
||||
"slug": "browse-posts",
|
||||
"showInNavigation": true,
|
||||
"description": "Load sample records and inspect a selected row.",
|
||||
"order": 1,
|
||||
"components": [
|
||||
{
|
||||
"id": "cmp_browse_title",
|
||||
"type": "Label",
|
||||
"name": "browseHeading",
|
||||
"position": { "x": 24, "y": 20 },
|
||||
"size": { "width": 600, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Posts for sample user 1",
|
||||
"value": "Posts for sample user 1",
|
||||
"visible": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cmp_refresh_posts",
|
||||
"type": "Button",
|
||||
"name": "refreshPosts",
|
||||
"position": { "x": 24, "y": 76 },
|
||||
"size": { "width": 160, "height": 44 },
|
||||
"properties": {
|
||||
"label": "Refresh posts",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{ "event": "onClick", "actionId": "action_load_posts" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_back_create",
|
||||
"type": "Button",
|
||||
"name": "backToCreate",
|
||||
"position": { "x": 204, "y": 76 },
|
||||
"size": { "width": 180, "height": 44 },
|
||||
"properties": {
|
||||
"label": "Back to create",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{ "event": "onClick", "navigateToPageId": "page_create_post" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_posts_table",
|
||||
"type": "Table",
|
||||
"name": "postsTable",
|
||||
"position": { "x": 24, "y": 144 },
|
||||
"size": { "width": 700, "height": 480 },
|
||||
"properties": {
|
||||
"label": "Sample posts",
|
||||
"columns": [
|
||||
{ "key": "id", "header": "ID", "width": 80 },
|
||||
{ "key": "userId", "header": "User", "width": 90 },
|
||||
{ "key": "title", "header": "Title", "width": 480 }
|
||||
],
|
||||
"rows": [],
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cmp_selected_post",
|
||||
"type": "JsonViewer",
|
||||
"name": "selectedPost",
|
||||
"position": { "x": 756, "y": 144 },
|
||||
"size": { "width": 404, "height": 480 },
|
||||
"properties": {
|
||||
"label": "Selected post",
|
||||
"defaultValue": null,
|
||||
"visible": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{ "event": "onLoad", "actionId": "action_load_posts" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "action_create_post",
|
||||
"name": "Create simulated post",
|
||||
"description": "Sends component and variable values to JSONPlaceholder. The service simulates creation without storing data.",
|
||||
"method": "POST",
|
||||
"url": "https://jsonplaceholder.typicode.com/posts",
|
||||
"headers": {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json; charset=UTF-8"
|
||||
},
|
||||
"queryParameters": {},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "{\"title\":\"{{components.postTitle.value}}\",\"body\":\"{{components.postBody.value}}\",\"userId\":{{variables.defaultUserId}}}",
|
||||
"authenticationType": "anonymous"
|
||||
},
|
||||
{
|
||||
"id": "action_load_posts",
|
||||
"name": "Load sample posts",
|
||||
"description": "Loads the ten sample posts belonging to JSONPlaceholder user 1.",
|
||||
"method": "GET",
|
||||
"url": "https://jsonplaceholder.typicode.com/posts",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"userId": "{{variables.defaultUserId}}"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "anonymous"
|
||||
}
|
||||
],
|
||||
"bindings": [
|
||||
{
|
||||
"id": "binding_created_id",
|
||||
"source": "actions.action_create_post.response.body.id",
|
||||
"target": "components.createdPostId.value",
|
||||
"trigger": "onSuccess"
|
||||
},
|
||||
{
|
||||
"id": "binding_create_response",
|
||||
"source": "actions.action_create_post.response.body",
|
||||
"target": "components.createResponse.value",
|
||||
"trigger": "onSuccess"
|
||||
},
|
||||
{
|
||||
"id": "binding_posts_rows",
|
||||
"source": "actions.action_load_posts.response.body",
|
||||
"target": "components.postsTable.rows",
|
||||
"trigger": "onSuccess"
|
||||
},
|
||||
{
|
||||
"id": "binding_selected_post",
|
||||
"source": "components.postsTable.selectedRow",
|
||||
"target": "components.selectedPost.value",
|
||||
"trigger": "onChange"
|
||||
}
|
||||
],
|
||||
"variables": {
|
||||
"defaultUserId": {
|
||||
"type": "number",
|
||||
"defaultValue": 1,
|
||||
"scope": "global",
|
||||
"description": "Sample user used by both tutorial actions."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"theme": "system",
|
||||
"defaultPageId": "page_create_post",
|
||||
"canvasWidth": 1200,
|
||||
"canvasHeight": 700
|
||||
}
|
||||
}
|
||||
}
|
||||
4
frontend/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "conductor-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "conductor-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "conductor-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
|
||||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
frontend/public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 838 B |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
6
frontend/public/favicon.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title id="title">Conductor</title>
|
||||
<rect x="2" y="2" width="60" height="60" rx="14" fill="#165BBA"/>
|
||||
<path d="M41 19 A16 16 0 1 0 41 45" fill="none" stroke="#FFF" stroke-width="7.5" stroke-linecap="round"/>
|
||||
<path d="M33 32 L49 19" fill="none" stroke="#FFF" stroke-width="4.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 436 B |
@ -4,6 +4,8 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Conductor</title>
|
||||
<link id="conductor-favicon" rel="icon" type="image/svg+xml" sizes="any" href="%PUBLIC_URL%/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@ -10,41 +10,36 @@ 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 InstalledApp from './components/InstalledApps/InstalledApp';
|
||||
import PublishedApp from './components/Published/PublishedApp';
|
||||
import UserManagement from './components/Admin/UserManagement';
|
||||
import Publishing from './components/Admin/Publishing';
|
||||
import FirstRunSetup from './components/Auth/FirstRunSetup';
|
||||
|
||||
// Placeholder panels — replaced with real implementations in later steps
|
||||
function PlaceholderPanel({ title, description }: { title: string; description: string }): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>{title}</h2>
|
||||
<p style={{ color: '#57606a', fontSize: 14 }}>{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import Projects from './components/Projects/Projects';
|
||||
import {BrowserIdentityProvider, usePageIdentity} from './components/Identity/BrowserIdentity';
|
||||
import {NAV_ITEMS} from './components/Layout/navItems';
|
||||
|
||||
function AppContent(): React.ReactElement {
|
||||
const [activeItem, setActiveItem] = useState<string>('');
|
||||
usePageIdentity(NAV_ITEMS.find(item=>item.id===activeItem)?.label);
|
||||
const [publishingProjectId, setPublishingProjectId] = useState<number>();
|
||||
const navigate = (id: string) => {setPublishingProjectId(undefined); setActiveItem(id);};
|
||||
|
||||
function renderContent(): React.ReactElement | null {
|
||||
if (!activeItem) return <WelcomePanel />;
|
||||
if (!activeItem) return <WelcomePanel onNavigate={navigate} />;
|
||||
if (activeItem === 'visual-editor') return <VisualEditor />;
|
||||
if (activeItem === 'projects') return (
|
||||
<PlaceholderPanel title="Projects" description="Project list — coming in a later step." />
|
||||
);
|
||||
if (activeItem === 'projects') return <Projects onOpen={navigate} onPublish={id => {setPublishingProjectId(id); setActiveItem('publishing');}} />;
|
||||
if (activeItem === 'json-editor') return <JsonEditor />;
|
||||
if (activeItem === 'preview') return <Preview />;
|
||||
if (activeItem === 'inspector') return <ActionInspector />;
|
||||
if (activeItem === 'history') return <ExecutionHistory />;
|
||||
if (activeItem === 'publishing') return <Publishing />;
|
||||
if (activeItem === 'publishing') return <Publishing initialProjectId={publishingProjectId} />;
|
||||
if (activeItem === 'users') return <UserManagement />;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout activeItem={activeItem} onNavigate={setActiveItem}>
|
||||
<Layout activeItem={activeItem} onNavigate={navigate}>
|
||||
{renderContent()}
|
||||
</Layout>
|
||||
);
|
||||
@ -58,6 +53,7 @@ function AuthenticatedApp(): React.ReactElement {
|
||||
if (loading) return <p style={{padding:32}}>Loading…</p>;
|
||||
if (setupRequired) return <FirstRunSetup />;
|
||||
if (!user) return <Login />;
|
||||
if (window.location.pathname !== '/') return <InstalledApp fallback={user.role==='user'?<AppCatalog/>:<ProjectProvider><AppContent/></ProjectProvider>}/>;
|
||||
if (user.role === 'user') return <AppCatalog />;
|
||||
return (
|
||||
<ProjectProvider>
|
||||
@ -66,6 +62,6 @@ function AuthenticatedApp(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
function App(): React.ReactElement { return <AuthProvider><AuthenticatedApp /></AuthProvider>; }
|
||||
function App(): React.ReactElement { return <AuthProvider><BrowserIdentityProvider><AuthenticatedApp /></BrowserIdentityProvider></AuthProvider>; }
|
||||
|
||||
export default App;
|
||||
|
||||
19
frontend/src/api/includedAppsApi.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { apiFetch } from './apiClient';
|
||||
|
||||
export type IncludedApp = {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
launchPath: string;
|
||||
configurationPath?: string;
|
||||
iconPath?: string;
|
||||
audience: 'all-users' | 'admins';
|
||||
};
|
||||
|
||||
export async function listIncludedApps(): Promise<IncludedApp[]> {
|
||||
const response = await apiFetch('/api/included-apps');
|
||||
if (!response.ok) throw new Error('Included applications could not be loaded. Please try again.');
|
||||
return response.json() as Promise<IncludedApp[]>;
|
||||
}
|
||||
@ -83,3 +83,22 @@ export async function saveProject(
|
||||
});
|
||||
return handleResponse<ApiProjectRow>(res);
|
||||
}
|
||||
|
||||
/** Create and immediately persist a blank builder project. */
|
||||
export async function createSavedProject(name: string, description: string): Promise<ApiProjectRow> {
|
||||
return handleResponse<ApiProjectRow>(await apiFetch(BASE, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, description}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Update metadata without sending a stale copy of the project document. */
|
||||
export async function updateProjectDetails(id: number, name: string, description: string, expectedUpdatedAt: string): Promise<ApiProjectRow> {
|
||||
return handleResponse<ApiProjectRow>(await apiFetch(BASE + '/' + id, {
|
||||
method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, description, expectedUpdatedAt}),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteSavedProject(id: number, updatedAt: string): Promise<void> {
|
||||
const response = await apiFetch(BASE + '/' + id, {method:'DELETE', headers:{'X-Project-Updated-At':updatedAt}});
|
||||
if (!response.ok) await handleResponse<never>(response);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
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 PublishedSummary={slug:string;displayName:string;description:string;visibility:'public'|'authenticated';version:number;iconPath?:string|null};
|
||||
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[]>);
|
||||
|
||||
@ -1,16 +1,80 @@
|
||||
import React from 'react';
|
||||
import { apiFetch } from '../../api/apiClient';
|
||||
import { listProjects } from '../../api/projectsApi';
|
||||
import { useProject } from '../../context/ProjectContext';
|
||||
import IncludedApps from '../IncludedApps/IncludedApps';
|
||||
import AppIcon from '../Identity/AppIcon';
|
||||
import IconPicker from '../Identity/IconPicker';
|
||||
import type { ApiProjectRow } from '../../types/project';
|
||||
import styles from '../Management.module.css';
|
||||
|
||||
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>;
|
||||
type Publication = { id:string; slug:string; sourceProjectId:number; displayName:string; description:string; visibility:'public'|'authenticated'; version:number; active:boolean; iconPath?:string|null };
|
||||
async function request<T>(url:string,init?:RequestInit):Promise<T>{
|
||||
const response=await apiFetch(url,init);
|
||||
if(!response.ok){const body=await response.json().catch(()=>({})) as {error?:string};throw new Error(body.error??('Request failed ('+response.status+').'));}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
export default function Publishing({initialProjectId}: {initialProjectId?: number}):React.ReactElement {
|
||||
const current=useProject();
|
||||
const [projects,setProjects]=React.useState<ApiProjectRow[]>([]),[apps,setApps]=React.useState<Publication[]>([]);
|
||||
const [error,setError]=React.useState(''),[loading,setLoading]=React.useState(true),[busy,setBusy]=React.useState(false);
|
||||
const [projectId,setProjectId]=React.useState(initialProjectId?String(initialProjectId):''),[name,setName]=React.useState(''),[slug,setSlug]=React.useState('');
|
||||
const [iconPath,setIconPath]=React.useState<string|null>(null),[iconBusy,setIconBusy]=React.useState(false);
|
||||
const [visibility,setVisibility]=React.useState<'public'|'authenticated'>('authenticated');
|
||||
const load=React.useCallback(async()=>{
|
||||
setLoading(true);setError('');
|
||||
try {
|
||||
const [saved,published]=await Promise.all([listProjects(),request<Publication[]>('/api/admin/published-apps')]);
|
||||
setProjects(saved);setApps(published);
|
||||
setProjectId(selected=>saved.some(p=>String(p.id)===selected)?selected:saved[0]?String(saved[0].id):'');
|
||||
} catch(e){setError(e instanceof Error?e.message:'Publishing could not be loaded.');}
|
||||
finally{setLoading(false);}
|
||||
},[]);
|
||||
React.useEffect(()=>{void load();},[load]);
|
||||
const publish=async(event:React.FormEvent)=>{
|
||||
event.preventDefault();setBusy(true);setError('');
|
||||
try{
|
||||
await request('/api/admin/published-apps',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sourceProjectId:Number(projectId),displayName:name.trim(),slug,description:'',visibility,iconPath})});
|
||||
setName('');setSlug('');setIconPath(null);await load();
|
||||
}catch(e){setError(e instanceof Error?e.message:'Publishing failed.');}finally{setBusy(false);}
|
||||
};
|
||||
const action=async(app:Publication,kind:'publish'|'unpublish')=>{
|
||||
setBusy(true);setError('');
|
||||
try{await request('/api/admin/published-apps/'+app.id+'/'+kind,{method:'POST'});await load();}
|
||||
catch(e){setError(e instanceof Error?e.message:'The publication could not be changed.');}finally{setBusy(false);}
|
||||
};
|
||||
const setAppIcon=async(app:Publication,next:string|null)=>{
|
||||
await request('/api/admin/published-apps/'+app.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({iconPath:next})});
|
||||
setApps(current=>current.map(item=>item.id===app.id?{...item,iconPath:next}:item));
|
||||
};
|
||||
return <div className={styles.page}>
|
||||
<section aria-labelledby="publishing-heading">
|
||||
<div className={styles.header}><div><h1 id="publishing-heading">Publishing</h1><p className={styles.muted}>Make your saved projects available as applications. Republish an application to release your latest saved changes.</p></div>
|
||||
<button className={styles.button} disabled={loading||busy||iconBusy} onClick={()=>void load()}>Refresh publishing</button></div>
|
||||
<h2>Applications you publish</h2>
|
||||
{current.isDirty&&<p className={styles.notice}>Your current editor has unsaved changes. Publishing uses the last saved version of a project.</p>}
|
||||
{error&&<p role="alert" className={styles.error}>{error}</p>}
|
||||
{loading&&<p role="status">Loading published applications…</p>}
|
||||
{!loading&&!error&&projects.length===0&&<p className={styles.empty}>Save a project in Conductor to publish your first application.</p>}
|
||||
{projects.length>0&&<form onSubmit={event=>void publish(event)} className={styles.form}>
|
||||
<label className={styles.field}>Saved project<select required value={projectId} onChange={e=>setProjectId(e.target.value)}>{projects.map(project=><option key={project.id} value={project.id}>{project.name}</option>)}</select></label>
|
||||
<label className={styles.field}>Application name<input required maxLength={120} value={name} onChange={e=>setName(e.target.value)} placeholder="Name shown to users"/></label>
|
||||
<label className={styles.field}>Application address<input required pattern="[a-z0-9]+(?:-[a-z0-9]+)*" value={slug} onChange={e=>setSlug(e.target.value)} placeholder="my-application"/></label>
|
||||
<label className={styles.field}>Access<select value={visibility} onChange={e=>setVisibility(e.target.value as typeof visibility)}><option value="authenticated">Signed-in users</option><option value="public">Public (anonymous actions only)</option></select></label>
|
||||
<IconPicker value={iconPath} onChange={next=>setIconPath(next)} disabled={busy||loading||iconBusy} onBusyChange={setIconBusy}/>
|
||||
<button className={styles.primary} disabled={busy||loading||iconBusy||!projectId}>Publish</button>
|
||||
</form>}
|
||||
{!loading&&!error&&apps.length===0&&projects.length>0&&<p className={styles.empty}>No applications have been published yet.</p>}
|
||||
{!error&&<div className={styles.grid}>{apps.map(app=><article className={styles.card} key={app.id}>
|
||||
<span className={styles.badge}>{app.active?'Published':'Unpublished'}</span><div className={styles.titleRow}><AppIcon iconPath={app.iconPath}/><h3>{app.displayName}</h3></div>
|
||||
<p className={styles.muted}>{app.visibility==='public'?'Public access':'Signed-in users'} · Version {app.version}</p>
|
||||
<code>/apps/{app.slug}</code>
|
||||
<details className={styles.iconDetails}><summary>Application icon</summary><IconPicker value={app.iconPath} onChange={next=>setAppIcon(app,next)} disabled={busy||loading||iconBusy} onBusyChange={setIconBusy}/></details>
|
||||
<div className={styles.actions}><button className={styles.button} disabled={busy||loading||iconBusy} onClick={()=>void action(app,'publish')}>{app.active?'Republish':'Publish'}</button>
|
||||
{app.active&&<button className={styles.button} disabled={busy||loading||iconBusy} onClick={()=>void action(app,'unpublish')}>Unpublish</button>}
|
||||
{app.active&&<a className={styles.primary} href={'/apps/'+app.slug}>Open app</a>}</div>
|
||||
</article>)}</div>}
|
||||
</section>
|
||||
<IncludedApps showConfiguration />
|
||||
</div>;
|
||||
}
|
||||
|
||||
9
frontend/src/components/Identity/AppIcon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import {DEFAULT_ICON,safeIconPath} from './BrowserIdentity';
|
||||
|
||||
export default function AppIcon({iconPath,size=36}:{iconPath?:string|null;size?:number}):React.ReactElement {
|
||||
const source=safeIconPath(iconPath);
|
||||
const [failed,setFailed]=React.useState(false);
|
||||
React.useEffect(()=>setFailed(false),[source]);
|
||||
return <img src={failed?DEFAULT_ICON:source} alt="" width={size} height={size} style={{width:size,height:size,objectFit:'contain',flexShrink:0,borderRadius:6}} onError={()=>{if(!failed)setFailed(true);}}/>;
|
||||
}
|
||||
58
frontend/src/components/Identity/BrowserIdentity.test.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React, {act} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {BrowserIdentityProvider, usePageIdentity} from './BrowserIdentity';
|
||||
import {useAuth} from '../../context/AuthContext';
|
||||
|
||||
jest.mock('../../context/AuthContext',()=>({useAuth:jest.fn()}));
|
||||
jest.mock('../../api/includedAppsApi',()=>({listIncludedApps:jest.fn().mockResolvedValue([])}));
|
||||
const auth=useAuth as jest.Mock;
|
||||
(globalThis as typeof globalThis&{IS_REACT_ACT_ENVIRONMENT:boolean}).IS_REACT_ACT_ENVIRONMENT=true;
|
||||
let container:HTMLDivElement,root:Root;
|
||||
function render(element:React.ReactElement){act(()=>root.render(element));return{rerender:(next:React.ReactElement)=>act(()=>root.render(next))};}
|
||||
function Page({name,icon}:{name:string;icon?:string}) {usePageIdentity(name,icon);return <p>{name}</p>;}
|
||||
let candidates:Array<{onload:(()=>void)|null;onerror:(()=>void)|null;naturalWidth:number;src:string}>;
|
||||
let imageMock:jest.SpyInstance;
|
||||
function favicon(){return document.querySelector<HTMLLinkElement>('#conductor-favicon')!;}
|
||||
|
||||
beforeEach(()=>{
|
||||
jest.useFakeTimers();candidates=[];
|
||||
container=document.createElement('div');document.body.appendChild(container);root=createRoot(container);
|
||||
window.history.replaceState({},'','/');
|
||||
auth.mockReturnValue({user:{id:'admin-1',role:'admin'},loading:false,setupRequired:false});
|
||||
imageMock=jest.spyOn(window,'Image').mockImplementation(()=>{
|
||||
const candidate={onload:null,onerror:null,naturalWidth:32,src:''};
|
||||
candidates.push(candidate);return candidate as unknown as HTMLImageElement;
|
||||
});
|
||||
document.head.innerHTML='<link id="conductor-favicon" rel="icon" href="/favicon.svg" />';
|
||||
});
|
||||
afterEach(()=>{act(()=>root.unmount());container.remove();imageMock.mockRestore();jest.useRealTimers();});
|
||||
|
||||
test('an earlier icon load cannot overwrite the next application identity',()=>{
|
||||
const screen=render(<BrowserIdentityProvider><Page name="First app" icon="/first.png"/></BrowserIdentityProvider>);
|
||||
const lateLoad=candidates[candidates.length-1].onload!;
|
||||
screen.rerender(<BrowserIdentityProvider><Page name="Second app"/></BrowserIdentityProvider>);
|
||||
act(()=>lateLoad());
|
||||
expect(document.title).toBe('Second app · Conductor');
|
||||
expect(favicon().getAttribute('href')).toBe('/favicon.svg');
|
||||
});
|
||||
|
||||
test('signing out clears app identity even if its icon finishes loading afterward',()=>{
|
||||
const screen=render(<BrowserIdentityProvider><Page name="Private app" icon="/private.png"/></BrowserIdentityProvider>);
|
||||
const lateLoad=candidates[candidates.length-1].onload!;
|
||||
auth.mockReturnValue({user:null,loading:false,setupRequired:false});
|
||||
screen.rerender(<BrowserIdentityProvider><Page name="Private app" icon="/private.png"/></BrowserIdentityProvider>);
|
||||
act(()=>lateLoad());
|
||||
expect(document.title).toBe('Sign in · Conductor');
|
||||
expect(favicon().getAttribute('href')).toBe('/favicon.svg');
|
||||
});
|
||||
|
||||
test('failed and timed-out icons retain a working default',()=>{
|
||||
const screen=render(<BrowserIdentityProvider><Page name="Unavailable icon" icon="/missing.png"/></BrowserIdentityProvider>);
|
||||
act(()=>candidates[candidates.length-1].onerror!());
|
||||
expect(favicon().getAttribute('href')).toBe('/favicon.svg');
|
||||
screen.rerender(<BrowserIdentityProvider><Page name="Slow icon" icon="/slow.png"/></BrowserIdentityProvider>);
|
||||
const lateLoad=candidates[candidates.length-1].onload!;
|
||||
act(()=>{jest.advanceTimersByTime(5001);lateLoad();});
|
||||
expect(document.title).toBe('Slow icon · Conductor');
|
||||
expect(favicon().getAttribute('href')).toBe('/favicon.svg');
|
||||
});
|
||||
79
frontend/src/components/Identity/BrowserIdentity.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import {useAuth} from '../../context/AuthContext';
|
||||
import {listIncludedApps, type IncludedApp} from '../../api/includedAppsApi';
|
||||
|
||||
export const DEFAULT_ICON = '/favicon.svg';
|
||||
export function safeIconPath(value?: string | null): string {
|
||||
if (!value || value.length>512 || !/^\/[A-Za-z0-9_./-]+\.(?:svg|png|ico|webp|jpe?g)$/i.test(value) || value.startsWith('//') || value.startsWith('/api/')) return DEFAULT_ICON;
|
||||
try {const parsed=new URL(value,window.location.origin);return parsed.origin===window.location.origin&&parsed.pathname===value?value:DEFAULT_ICON;} catch {return DEFAULT_ICON;}
|
||||
}
|
||||
export function browserTitle(name?: string): string {return name&&name!=='Conductor'?name+' · Conductor':'Conductor';}
|
||||
export function matchingIncludedApp(apps: IncludedApp[], pathname: string): IncludedApp | undefined {
|
||||
return apps.filter(app=>{
|
||||
const route=new URL(app.launchPath,window.location.origin).pathname.replace(/\/$/,'');
|
||||
return pathname===route||pathname.startsWith(route+'/');
|
||||
}).sort((a,b)=>new URL(b.launchPath,window.location.origin).pathname.length-new URL(a.launchPath,window.location.origin).pathname.length)[0];
|
||||
}
|
||||
|
||||
type Identity={owner:symbol;scope:string;name?:string;iconPath?:string|null};
|
||||
type Controls={set:(owner:symbol,name?:string,iconPath?:string|null)=>void;clear:(owner:symbol)=>void};
|
||||
const Context=React.createContext<Controls|null>(null);
|
||||
|
||||
export function BrowserIdentityProvider({children}:{children:React.ReactNode}):React.ReactElement {
|
||||
const {user,loading,setupRequired}=useAuth();
|
||||
const scope=user?.id||'anonymous',isSignedIn=!!user;
|
||||
const [pathname,setPathname]=React.useState(window.location.pathname);
|
||||
const [view,setView]=React.useState<Identity|null>(null);
|
||||
const [installed,setInstalled]=React.useState<{scope:string;pathname:string;app?:IncludedApp}|null>(null);
|
||||
React.useEffect(()=>{
|
||||
const changed=()=>setPathname(window.location.pathname);
|
||||
window.addEventListener('popstate',changed);
|
||||
return()=>window.removeEventListener('popstate',changed);
|
||||
},[]);
|
||||
React.useEffect(()=>{
|
||||
let active=true;
|
||||
if(!isSignedIn||pathname==='/'||pathname.startsWith('/apps/')){setInstalled(null);return;}
|
||||
listIncludedApps().then(apps=>{if(active)setInstalled({scope,pathname,app:matchingIncludedApp(apps,pathname)});})
|
||||
.catch(()=>{if(active)setInstalled(null);});
|
||||
return()=>{active=false;};
|
||||
},[scope,user?.role,pathname,isSignedIn]);
|
||||
const set=React.useCallback((owner:symbol,name?:string,iconPath?:string|null)=>setView({owner,scope,name,iconPath}),[scope]);
|
||||
const clear=React.useCallback((owner:symbol)=>setView(current=>current?.owner===owner?null:current),[]);
|
||||
const controls=React.useMemo(()=>({set,clear}),[set,clear]);
|
||||
const currentView=view?.scope===scope?view:null;
|
||||
const app=!loading&&user&&installed?.scope===scope&&installed.pathname===pathname?installed.app:undefined;
|
||||
const publicRoute=pathname.startsWith('/apps/');
|
||||
const name=loading?undefined:setupRequired?'Set up':!user&&!publicRoute?'Sign in':app?.name??currentView?.name;
|
||||
const iconPath=safeIconPath(!loading&&(user||publicRoute)?app?.iconPath??currentView?.iconPath:undefined);
|
||||
React.useLayoutEffect(()=>{
|
||||
document.title=browserTitle(name);
|
||||
let link=document.querySelector<HTMLLinkElement>('#conductor-favicon');
|
||||
if(!link){link=document.createElement('link');link.id='conductor-favicon';link.rel='icon';document.head.appendChild(link);}
|
||||
const favicon=link;
|
||||
favicon.href=DEFAULT_ICON;favicon.type='image/svg+xml';favicon.setAttribute('sizes','any');
|
||||
if(iconPath===DEFAULT_ICON)return;
|
||||
let active=true;
|
||||
const candidate=new Image();
|
||||
const timeout=window.setTimeout(()=>{active=false;},5000);
|
||||
candidate.onload=()=>{
|
||||
if(!active||!candidate.naturalWidth)return;
|
||||
favicon.href=iconPath;
|
||||
favicon.type=iconPath.toLowerCase().endsWith('.svg')?'image/svg+xml':iconPath.toLowerCase().endsWith('.ico')?'image/x-icon':iconPath.toLowerCase().endsWith('.webp')?'image/webp':/\.jpe?g$/i.test(iconPath)?'image/jpeg':'image/png';
|
||||
favicon.removeAttribute('sizes');window.clearTimeout(timeout);
|
||||
};
|
||||
candidate.onerror=()=>window.clearTimeout(timeout);
|
||||
candidate.src=iconPath;
|
||||
return()=>{active=false;window.clearTimeout(timeout);candidate.onload=null;candidate.onerror=null;};
|
||||
},[name,iconPath]);
|
||||
return <Context.Provider value={controls}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
export function usePageIdentity(name?:string,iconPath?:string|null):void {
|
||||
const controls=React.useContext(Context),owner=React.useRef(Symbol('page-identity'));
|
||||
React.useLayoutEffect(()=>{
|
||||
if(!controls)return;
|
||||
controls.set(owner.current,name,iconPath);
|
||||
const id=owner.current;
|
||||
return()=>controls.clear(id);
|
||||
},[controls,name,iconPath]);
|
||||
}
|
||||
35
frontend/src/components/Identity/IconPicker.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import {apiFetch} from '../../api/apiClient';
|
||||
import AppIcon from './AppIcon';
|
||||
import styles from '../Management.module.css';
|
||||
|
||||
type Props={value?:string|null;onChange:(path:string|null)=>Promise<void>|void;disabled?:boolean;onBusyChange?:(busy:boolean)=>void};
|
||||
export default function IconPicker({value,onChange,disabled=false,onBusyChange}:Props):React.ReactElement {
|
||||
const id=React.useId(),[busy,setBusy]=React.useState(false),[error,setError]=React.useState('');
|
||||
async function change(file?:File){
|
||||
if(!file||busy||disabled)return;
|
||||
setError('');
|
||||
if(file.size>256*1024){setError('Choose a PNG image up to 256 KB.');return;}
|
||||
setBusy(true);onBusyChange?.(true);
|
||||
try{
|
||||
const response=await apiFetch('/api/app-icons',{method:'POST',headers:{'Content-Type':'image/png'},body:file});
|
||||
const result=await response.json() as {iconPath?:string;error?:string};
|
||||
if(!response.ok||!result.iconPath)throw new Error(result.error||'The icon could not be uploaded.');
|
||||
await onChange(result.iconPath);
|
||||
}catch(e){setError(e instanceof Error?e.message:'The icon could not be changed.');}
|
||||
finally{setBusy(false);onBusyChange?.(false);}
|
||||
}
|
||||
async function reset(){
|
||||
setError('');setBusy(true);onBusyChange?.(true);
|
||||
try{await onChange(null);}catch(e){setError(e instanceof Error?e.message:'The default icon could not be restored.');}
|
||||
finally{setBusy(false);onBusyChange?.(false);}
|
||||
}
|
||||
return <div className={styles.iconPicker}>
|
||||
<div className={styles.titleRow}><AppIcon iconPath={value}/><span>{value?'Custom application icon':'Conductor default icon'}</span></div>
|
||||
<label className={styles.field} htmlFor={id}>Application icon (optional)<input id={id} type="file" accept="image/png,.png" disabled={disabled||busy} onChange={event=>{const file=event.target.files?.[0];event.target.value='';void change(file);}}/></label>
|
||||
<small className={styles.muted}>Square PNG, 16–512 pixels, up to 256 KB.</small>
|
||||
{value&&<button type="button" className={styles.button} disabled={disabled||busy} onClick={()=>void reset()}>Use Conductor icon</button>}
|
||||
{busy&&<span role="status">Saving icon…</span>}
|
||||
{error&&<p role="alert" className={styles.error}>{error}</p>}
|
||||
</div>;
|
||||
}
|
||||
36
frontend/src/components/IncludedApps/IncludedApps.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { listIncludedApps, type IncludedApp } from '../../api/includedAppsApi';
|
||||
import styles from '../Management.module.css';
|
||||
import AppIcon from '../Identity/AppIcon';
|
||||
|
||||
export default function IncludedApps({ showConfiguration = false }: {showConfiguration?: boolean}): React.ReactElement {
|
||||
const [apps, setApps] = useState<IncludedApp[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try { setApps(await listIncludedApps()); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Applications could not be loaded.'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
return <section className={styles.section} aria-labelledby="included-apps-heading">
|
||||
<div className={styles.sectionHeader}>
|
||||
<div><h2 id="included-apps-heading">Included with Skeleton Works</h2>
|
||||
<p className={styles.muted}>Applications installed for your workspace.</p></div>
|
||||
<button className={styles.button} onClick={() => void load()} disabled={loading}>Refresh applications</button>
|
||||
</div>
|
||||
{loading && <p role="status">Loading included applications…</p>}
|
||||
{error && <p className={styles.error} role="alert">{error}</p>}
|
||||
{!loading && !error && apps.length === 0 && <div className={styles.empty}>No included applications are installed yet. They will appear here when your VM administrator installs them.</div>}
|
||||
{!error && <div className={styles.grid}>{apps.map(app => <article className={styles.card} key={app.id}>
|
||||
<span className={styles.badge}>Included application</span><div className={styles.titleRow}><AppIcon iconPath={app.iconPath}/><h3>{app.name}</h3></div>
|
||||
<p className={styles.muted}>{app.description}</p>
|
||||
<small className={styles.muted}>Version {app.version}</small>
|
||||
<div className={styles.actions}>
|
||||
<a className={styles.primary} href={app.launchPath}>Open {app.name}</a>
|
||||
{showConfiguration && app.configurationPath && <a className={styles.button} href={app.configurationPath}>Configure</a>}
|
||||
</div>
|
||||
</article>)}</div>}
|
||||
</section>;
|
||||
}
|
||||
44
frontend/src/components/InstalledApps/InstalledApp.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import {apiFetch} from '../../api/apiClient';
|
||||
import {useAuth} from '../../context/AuthContext';
|
||||
import {usePageIdentity} from '../Identity/BrowserIdentity';
|
||||
type Runtime={id:string;name:string;launchPath:string;iconPath?:string;script:string;style?:string;apiBasePath?:string;hostApiVersion:1};
|
||||
type Module={mount:(element:HTMLElement,context:{hostApiVersion:1;application:Runtime;user:{id:string;displayName:string;role:string}})=>(()=>void)|void};
|
||||
export default function InstalledApp({fallback}:{fallback:React.ReactElement}):React.ReactElement{
|
||||
const {user}=useAuth(),element=React.useRef<HTMLDivElement>(null);
|
||||
const [result,setResult]=React.useState<{key:string;app:Runtime|null}|null>(null),[error,setError]=React.useState('');
|
||||
const key=window.location.pathname+'|'+user?.id+'|'+user?.role;
|
||||
const app=result?.key===key?result.app:undefined;
|
||||
usePageIdentity(app?.name,error?'':app?.iconPath);
|
||||
React.useEffect(()=>{
|
||||
let active=true;setError('');setResult(null);
|
||||
apiFetch('/api/installed-apps/resolve?path='+encodeURIComponent(window.location.pathname)).then(async response=>{
|
||||
if(!response.ok)throw new Error(response.status===403?'Administrator access is required.':'The application could not be loaded.');
|
||||
const value=await response.json() as Runtime|null;if(active)setResult({key,app:value});
|
||||
}).catch(e=>{if(active)setError(e instanceof Error?e.message:'The application could not be loaded.');});
|
||||
return()=>{active=false;};
|
||||
},[key]);
|
||||
React.useEffect(()=>{
|
||||
if(!app||!user||!element.current)return;
|
||||
let active=true,unmount:(()=>void)|void;let link:HTMLLinkElement|undefined;
|
||||
const node=element.current;
|
||||
const timeout=window.setTimeout(()=>{if(active){active=false;link?.remove();setError('The application took too long to load. Refresh to try again.');}},15000);
|
||||
async function load(){
|
||||
if(app!.style){
|
||||
link=document.createElement('link');link.rel='stylesheet';link.href=app!.style;
|
||||
await new Promise<void>((resolve,reject)=>{link!.onload=()=>resolve();link!.onerror=()=>reject(new Error('Application styles could not be loaded.'));document.head.appendChild(link!);});
|
||||
}
|
||||
const module=await import(/* webpackIgnore: true */ app!.script) as Module;
|
||||
if(!active)return;
|
||||
if(typeof module.mount!=='function')throw new Error('The application package has no supported entry point.');
|
||||
unmount=module.mount(node,{hostApiVersion:1,application:app!,user:{id:user!.id,displayName:user!.displayName||user!.username,role:user!.role}});
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
void load().catch(e=>{if(active){setError(e instanceof Error?e.message:'The application could not start.');window.clearTimeout(timeout);}});
|
||||
return()=>{active=false;window.clearTimeout(timeout);if(typeof unmount==='function')unmount();link?.remove();};
|
||||
},[app,user]);
|
||||
if(error)return <main style={{padding:32}}><h1>Application unavailable</h1><p>{error}</p><a href="/">Return to Conductor</a></main>;
|
||||
if(app===null)return fallback;
|
||||
if(!app)return <p style={{padding:32}}>Loading application…</p>;
|
||||
return <div ref={element}/>;
|
||||
}
|
||||
37
frontend/src/components/Management.module.css
Normal file
@ -0,0 +1,37 @@
|
||||
.page {max-width:1120px;color:#1f2937;}
|
||||
.header,.sectionHeader {display:flex;align-items:flex-start;justify-content:space-between;gap:20px;flex-wrap:wrap;margin-bottom:20px;}
|
||||
.header h1,.header h2 {margin:0 0 8px;font-size:26px;}
|
||||
.sectionHeader h2 {margin:0 0 6px;font-size:21px;}
|
||||
.muted {color:#586575;line-height:1.6;}
|
||||
.section {margin-top:32px;padding-top:26px;border-top:1px solid #e2e7ee;}
|
||||
.grid {display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:16px;}
|
||||
.card {min-width:0;padding:22px;border:1px solid #dde3eb;border-radius:12px;background:#fff;overflow-wrap:anywhere;}
|
||||
.card h3 {margin:12px 0 6px;font-size:19px;}
|
||||
.card p {margin:8px 0 14px;}
|
||||
.actions {display:flex;gap:9px;align-items:center;flex-wrap:wrap;margin-top:18px;}
|
||||
.button,.primary,.danger {display:inline-flex;justify-content:center;align-items:center;padding:9px 14px;border:1px solid #cbd4df;border-radius:7px;font:inherit;font-size:14px;font-weight:600;line-height:1.4;text-decoration:none;cursor:pointer;background:#fff;color:#284567;}
|
||||
.primary {background:#165bba;border-color:#165bba;color:white;}
|
||||
.danger {color:#b42318;border-color:#e9b9b4;}
|
||||
.button:hover {background:#f1f5f9;}.primary:hover {background:#104b9e;}
|
||||
.button:focus-visible,.primary:focus-visible,.danger:focus-visible {outline:3px solid #82b4f7;outline-offset:2px;}
|
||||
.button:disabled,.primary:disabled,.danger:disabled {opacity:.5;cursor:not-allowed;}
|
||||
.badge {display:inline-block;font-size:12px;font-weight:600;background:#edf4ff;border:1px solid #d3e4ff;border-radius:20px;padding:4px 9px;color:#24578f;}
|
||||
.empty {border:1px dashed #cbd4df;border-radius:10px;padding:24px;color:#586575;line-height:1.6;background:#f8fafc;}
|
||||
.form {display:flex;gap:14px;flex-wrap:wrap;align-items:flex-end;padding:20px;border:1px solid #dde3eb;border-radius:12px;background:#f8fafc;margin-bottom:20px;}
|
||||
.field {display:flex;flex-direction:column;gap:7px;flex:1 1 200px;font-size:14px;font-weight:600;min-width:0;}
|
||||
.field input,.field select,.field textarea,.search {padding:10px 12px;font:inherit;font-weight:400;border:1px solid #bdc8d5;border-radius:6px;background:white;box-sizing:border-box;width:100%;min-width:0;}
|
||||
.search {max-width:380px;margin-bottom:18px;}
|
||||
.error {padding:12px 16px;border:1px solid #f3b9b5;border-radius:8px;background:#fff2f1;color:#a6271e;}
|
||||
.notice {padding:12px 16px;border:1px solid #bad8f5;border-radius:8px;background:#f0f7ff;color:#224d76;}
|
||||
.details {display:flex;gap:12px;flex-wrap:wrap;font-size:13px;color:#586575;margin-top:10px;}
|
||||
.inlineForm {display:grid;gap:12px;margin-top:12px;}
|
||||
@media(max-width:600px){.card {padding:16px;}.form {padding:16px;}.header h1 {font-size:23px;}}
|
||||
|
||||
.titleRow {display:flex;gap:12px;align-items:center;min-width:0;}
|
||||
.titleRow h3 {margin:12px 0;}
|
||||
.iconPicker {display:flex;flex-direction:column;gap:10px;flex:1 1 260px;min-width:0;}
|
||||
.iconPicker .field {flex:initial;}
|
||||
.iconPicker input[type=file] {font-size:13px;max-width:100%;}
|
||||
.iconPicker .button {align-self:flex-start;}
|
||||
.iconDetails {margin-top:16px;}
|
||||
.iconDetails summary {cursor:pointer;font-weight:600;color:#284567;margin-bottom:12px;}
|
||||
@ -49,7 +49,7 @@
|
||||
* 3. Resolves each source path against the stored response
|
||||
* 4. Writes resolved values to componentState[targetId].value
|
||||
*
|
||||
* Supported source paths: See docs/response-mapping-model.md §4
|
||||
* Supported source paths: See docs/SCHEMA.md § Runtime bindings
|
||||
* Supported target paths: components.<name>.value (display and input components)
|
||||
* variables.<name> (Step 18.1)
|
||||
* Supported triggers: onSuccess (canonical), onClick (legacy compat)
|
||||
|
||||
105
frontend/src/components/Projects/Projects.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useProject } from '../../context/ProjectContext';
|
||||
import * as api from '../../api/projectsApi';
|
||||
import type { ApiProjectRow } from '../../types/project';
|
||||
import styles from '../Management.module.css';
|
||||
|
||||
type Props = { onOpen: (view: 'visual-editor' | 'preview') => void; onPublish: (id: number) => void };
|
||||
export default function Projects({ onOpen, onPublish }: Props): React.ReactElement {
|
||||
const current = useProject();
|
||||
const [projects, setProjects] = useState<ApiProjectRow[]>([]);
|
||||
const [loading, setLoading] = useState(true), [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(''), [notice, setNotice] = useState(''), [query, setQuery] = useState('');
|
||||
const [creating, setCreating] = useState(false), [name, setName] = useState(''), [description, setDescription] = useState('');
|
||||
const [editing, setEditing] = useState<number | null>(null), [editName, setEditName] = useState(''), [editDescription, setEditDescription] = useState('');
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try { setProjects(await api.listProjects()); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Projects could not be loaded.'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
const pending = busy || current.isLoading || current.isSaving;
|
||||
const mayReplace = () => !current.isDirty || window.confirm('Discard the unsaved changes to "' + current.projectName + '" and continue?');
|
||||
async function perform(action: () => Promise<void>) {
|
||||
setBusy(true); setError(''); setNotice('');
|
||||
try { await action(); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'The project operation failed.'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function open(project: ApiProjectRow, view: 'visual-editor' | 'preview') {
|
||||
if (project.id === current.projectRowId && current.isDirty) { onOpen(view); return; }
|
||||
if (!mayReplace()) return;
|
||||
await perform(async () => {
|
||||
if (!await current.loadProject(project.id)) throw new Error('The project could not be opened. Refresh the list and try again.');
|
||||
onOpen(view);
|
||||
});
|
||||
}
|
||||
async function create(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!name.trim() || !mayReplace()) return;
|
||||
await perform(async () => {
|
||||
const saved = await api.createSavedProject(name.trim(), description.trim());
|
||||
setCreating(false); setName(''); setDescription('');
|
||||
await refresh();
|
||||
if (await current.loadProject(saved.id)) onOpen('visual-editor');
|
||||
else setNotice('The project was created. Open it from this list when the connection is available.');
|
||||
});
|
||||
}
|
||||
async function rename(event: React.FormEvent, project: ApiProjectRow) {
|
||||
event.preventDefault();
|
||||
await perform(async () => {
|
||||
if (project.id === current.projectRowId && current.isDirty) throw new Error('Save your current editor changes before renaming this project from the list.');
|
||||
await api.updateProjectDetails(project.id, editName.trim(), editDescription.trim(), project.updated_at);
|
||||
setEditing(null);
|
||||
if (project.id === current.projectRowId) await current.loadProject(project.id);
|
||||
await refresh(); await current.refreshProjectList();
|
||||
setNotice('Project details updated.');
|
||||
});
|
||||
}
|
||||
async function remove(project: ApiProjectRow) {
|
||||
const extra = project.id === current.projectRowId && current.isDirty ? ' Its unsaved editor changes will also be discarded.' : '';
|
||||
if (!window.confirm('Delete "' + project.name + '"? This cannot be undone.' + extra)) return;
|
||||
await perform(async () => {
|
||||
await api.deleteSavedProject(project.id, project.updated_at);
|
||||
if (project.id === current.projectRowId) current.newProject();
|
||||
if (editing === project.id) setEditing(null);
|
||||
await refresh(); await current.refreshProjectList(); setNotice('Project deleted.');
|
||||
});
|
||||
}
|
||||
const shown = projects.filter(project => (project.name + ' ' + project.description).toLocaleLowerCase().includes(query.toLocaleLowerCase()));
|
||||
return <section className={styles.page}>
|
||||
<div className={styles.header}><div><h1>Projects</h1><p className={styles.muted}>Build, save, and edit your Conductor projects. Publish a saved project when it is ready to use.</p></div>
|
||||
<div className={styles.actions}><button className={styles.button} disabled={loading || pending} onClick={() => void refresh()}>Refresh projects</button>
|
||||
<button className={styles.primary} disabled={pending} onClick={() => setCreating(true)}>New project</button></div></div>
|
||||
{current.isDirty && <p className={styles.notice}>You have unsaved changes to <strong>{current.projectName}</strong>. <button className={styles.button} onClick={() => onOpen('visual-editor')}>Continue editing</button></p>}
|
||||
{error && <p role="alert" className={styles.error}>{error}</p>}
|
||||
{notice && <p role="status" className={styles.notice}>{notice}</p>}
|
||||
{creating && <form className={styles.form} onSubmit={event => void create(event)}>
|
||||
<label className={styles.field}>Project name<input autoFocus required maxLength={200} value={name} onChange={event => setName(event.target.value)} /></label>
|
||||
<label className={styles.field}>Description<input maxLength={2000} value={description} onChange={event => setDescription(event.target.value)} /></label>
|
||||
<button className={styles.primary} disabled={pending || !name.trim()}>Create project</button>
|
||||
<button type="button" className={styles.button} disabled={pending} onClick={() => setCreating(false)}>Cancel</button>
|
||||
</form>}
|
||||
{loading && <p role="status">Loading projects…</p>}
|
||||
{!loading && !error && projects.length === 0 && <div className={styles.empty}><strong>No saved projects yet.</strong><p>Create your first project, or save your work from the Visual Editor or JSON Editor.</p></div>}
|
||||
{projects.length > 0 && <label className={styles.field} style={{maxWidth:380,marginBottom:20}}>Search projects<input type="search" value={query} onChange={event => setQuery(event.target.value)} placeholder="Find by name or description" /></label>}
|
||||
{!loading && !error && projects.length > 0 && shown.length === 0 && <p className={styles.empty}>No projects match your search.</p>}
|
||||
{!error && <div className={styles.grid}>{shown.map(project => <article className={styles.card} key={project.id} aria-label={'Project ' + project.name}>
|
||||
<span className={styles.badge}>{project.id === current.projectRowId ? 'Open in editor' : 'Conductor project'}</span>
|
||||
{editing === project.id ? <form className={styles.inlineForm} onSubmit={event => void rename(event, project)}>
|
||||
<label className={styles.field}>Project name<input required maxLength={200} value={editName} onChange={event => setEditName(event.target.value)} /></label>
|
||||
<label className={styles.field}>Description<textarea maxLength={2000} value={editDescription} onChange={event => setEditDescription(event.target.value)} /></label>
|
||||
<div className={styles.actions}><button className={styles.primary} disabled={pending || !editName.trim()}>Save details</button><button type="button" className={styles.button} disabled={pending} onClick={() => setEditing(null)}>Cancel</button></div>
|
||||
</form> : <><h3>{project.name}</h3><p className={styles.muted}>{project.description || 'No description yet.'}</p>
|
||||
<div className={styles.details}><span>Updated {new Date(project.updated_at).toLocaleString()}</span></div>
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.primary} disabled={pending} onClick={() => void open(project, 'visual-editor')}>Open editor</button>
|
||||
<button className={styles.button} disabled={pending} onClick={() => void open(project, 'preview')}>Preview</button>
|
||||
<button className={styles.button} disabled={pending} onClick={() => onPublish(project.id)}>Publish</button>
|
||||
<button className={styles.button} disabled={pending || (project.id === current.projectRowId && current.isDirty)} onClick={() => {setEditing(project.id); setEditName(project.name); setEditDescription(project.description);}}>Rename</button>
|
||||
<button className={styles.danger} disabled={pending} onClick={() => void remove(project)}>Delete</button>
|
||||
</div></>}
|
||||
</article>)}</div>}
|
||||
</section>;
|
||||
}
|
||||
@ -1,2 +1,28 @@
|
||||
import React from 'react'; import { listPublished, type PublishedSummary } from '../../api/publishedAppsApi'; import { useAuth } from '../../context/AuthContext'; import ChangePassword from '../Auth/ChangePassword';
|
||||
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||user?.username}</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><ChangePassword/></main>}
|
||||
import React from 'react';
|
||||
import { listPublished, type PublishedSummary } from '../../api/publishedAppsApi';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import ChangePassword from '../Auth/ChangePassword';
|
||||
import IncludedApps from '../IncludedApps/IncludedApps';
|
||||
import styles from '../Management.module.css';
|
||||
import AppIcon from '../Identity/AppIcon';
|
||||
import {usePageIdentity} from '../Identity/BrowserIdentity';
|
||||
|
||||
export default function AppCatalog():React.ReactElement {
|
||||
const {user,logout}=useAuth();
|
||||
usePageIdentity('Applications');
|
||||
const [apps,setApps]=React.useState<PublishedSummary[]>([]),[error,setError]=React.useState(''),[loading,setLoading]=React.useState(true);
|
||||
React.useEffect(()=>{listPublished().then(setApps).catch(()=>setError('Published applications could not be loaded. Refresh this page to try again.')).finally(()=>setLoading(false));},[]);
|
||||
return <main className={styles.page} style={{margin:'40px auto',padding:24}}>
|
||||
<div className={styles.header}><div><h1>Applications</h1><p className={styles.muted}>Signed in as {user?.displayName||user?.username}</p></div><button className={styles.button} onClick={()=>void logout()}>Sign out</button></div>
|
||||
<h2>Published for your workspace</h2>
|
||||
{error&&<p role="alert" className={styles.error}>{error}</p>}
|
||||
{loading&&<p role="status">Loading applications…</p>}
|
||||
{!loading&&!error&&!apps.length&&<p className={styles.empty}>No Conductor applications are currently published.</p>}
|
||||
<div className={styles.grid}>{apps.map(app=><article key={app.slug} className={styles.card}>
|
||||
<div className={styles.titleRow}><AppIcon iconPath={app.iconPath}/><h3>{app.displayName}</h3></div><p className={styles.muted}>{app.description}</p><small className={styles.muted}>{app.visibility==='public'?'Public':'Sign-in required'} · Version {app.version}</small>
|
||||
<div className={styles.actions}><a className={styles.primary} href={'/apps/'+app.slug}>Open {app.displayName}</a></div>
|
||||
</article>)}</div>
|
||||
<IncludedApps />
|
||||
<section className={styles.section}><ChangePassword/></section>
|
||||
</main>;
|
||||
}
|
||||
|
||||
@ -1,23 +1,31 @@
|
||||
import React from 'react';
|
||||
import Preview from '../Preview/Preview';
|
||||
import Login from '../Auth/Login';
|
||||
import AppIcon from '../Identity/AppIcon';
|
||||
import {usePageIdentity} from '../Identity/BrowserIdentity';
|
||||
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, pageSlug }: { slug: string; pageSlug?: string }): React.ReactElement {
|
||||
const { user, loading } = useAuth();
|
||||
const [app, setApp] = React.useState<PublishedRuntime | null>(null);
|
||||
const [loaded, setLoaded] = React.useState<{key:string;app:PublishedRuntime} | null>(null);
|
||||
const requestKey=slug+'|'+(user?.id||'anonymous');
|
||||
const app=loaded?.key===requestKey&&!loading?loaded.app:null;
|
||||
const [error, setError] = React.useState('');
|
||||
const [needsAuth, setNeedsAuth] = React.useState(false);
|
||||
usePageIdentity(app?.displayName || (needsAuth&&!user?'Sign in':error?'Application unavailable':undefined),app?.iconPath);
|
||||
React.useEffect(() => {
|
||||
let active=true;
|
||||
setError('');setNeedsAuth(false);setLoaded(null);
|
||||
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);
|
||||
getPublished(slug).then(value => { if(active){setLoaded({key:requestKey,app:value});setNeedsAuth(false);} }).catch(e => {
|
||||
if(!active)return;
|
||||
const message=e instanceof Error?e.message:String(e);
|
||||
setNeedsAuth(message.includes('Authentication'));setError(message);
|
||||
});
|
||||
}, [slug, user, loading]);
|
||||
return()=>{active=false;};
|
||||
}, [slug, requestKey, 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>;
|
||||
@ -39,5 +47,5 @@ export default function PublishedApp({ slug, pageSlug }: { slug: string; pageSlu
|
||||
return executePublished(slug, action.id, componentValues, variables, pageId);
|
||||
};
|
||||
|
||||
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 initialPageSlug={pageSlug} /></main>;
|
||||
return <main><div style={{ padding: '12px 24px', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #d0d7de' }}><span style={{display:'flex',alignItems:'center',gap:10}}><AppIcon iconPath={app.iconPath} size={28}/><strong>{app.displayName}</strong></span>{user && <span>Signed in as {user.displayName}</span>}</div><Preview document={app.document} projectName={app.displayName} executor={executor} published initialPageSlug={pageSlug} /></main>;
|
||||
}
|
||||
|
||||
@ -1,53 +1,9 @@
|
||||
.panel {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #1f2328;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #57606a;
|
||||
margin-bottom: 28px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cardGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2328;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
font-size: 13px;
|
||||
color: #57606a;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 28px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
font-size: 13px;
|
||||
color: #15803d;
|
||||
}
|
||||
.panel {max-width:1120px;}
|
||||
.title {font-size:28px;font-weight:650;color:#1f2937;margin:0 0 10px;}
|
||||
.subtitle {font-size:15px;color:#586575;margin-bottom:28px;line-height:1.7;max-width:760px;}
|
||||
.cardGrid {display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:14px;}
|
||||
.card {padding:22px;border:1px solid #dce4ed;border-radius:10px;background:#f8fafc;text-align:left;font:inherit;cursor:pointer;}
|
||||
.card:hover {background:#f0f6ff;border-color:#a5c5ee;}
|
||||
.card:focus-visible {outline:3px solid #82b4f7;outline-offset:2px;}
|
||||
.cardTitle {display:block;font-size:16px;font-weight:650;color:#164f91;margin-bottom:8px;}
|
||||
.cardBody {display:block;font-size:14px;color:#586575;line-height:1.6;}
|
||||
|
||||
@ -1,48 +1,21 @@
|
||||
import React from 'react';
|
||||
import IncludedApps from './IncludedApps/IncludedApps';
|
||||
import styles from './WelcomePanel.module.css';
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
title: 'Projects',
|
||||
body: 'Create and manage your UI projects.',
|
||||
},
|
||||
{
|
||||
title: 'Visual Editor',
|
||||
body: 'Drag and drop components onto the canvas.',
|
||||
},
|
||||
{
|
||||
title: 'JSON Editor',
|
||||
body: 'Edit the project definition directly.',
|
||||
},
|
||||
{
|
||||
title: 'Preview',
|
||||
body: 'Run the application as an end user.',
|
||||
},
|
||||
{ id: 'projects', title: 'Projects', body: 'Create a project or continue working on one you have saved.' },
|
||||
{ id: 'publishing', title: 'Publishing', body: 'Release your applications and open applications included with your workspace.' },
|
||||
{ id: 'visual-editor', title: 'Visual Editor', body: 'Build an interface by adding and arranging components.' },
|
||||
{ id: 'json-editor', title: 'JSON Editor', body: 'View and edit the definition of your current project.' },
|
||||
{ id: 'preview', title: 'Preview', body: 'Try your current project before publishing it.' },
|
||||
];
|
||||
|
||||
function WelcomePanel(): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<h1 className={styles.title}>Welcome to Conductor</h1>
|
||||
<p className={styles.subtitle}>
|
||||
A drag-and-drop UI builder for REST API integrations.
|
||||
Select an item from the sidebar to get started.
|
||||
</p>
|
||||
|
||||
<div className={styles.cardGrid}>
|
||||
{CARDS.map((card) => (
|
||||
<div key={card.title} className={styles.card}>
|
||||
<div className={styles.cardTitle}>{card.title}</div>
|
||||
<div className={styles.cardBody}>{card.body}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.status}>
|
||||
✓ Backend scaffold ready — Step 2 complete
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function WelcomePanel({ onNavigate }: {onNavigate: (id: string) => void}): React.ReactElement {
|
||||
return <div className={styles.panel}>
|
||||
<h1 className={styles.title}>Welcome to Conductor</h1>
|
||||
<p className={styles.subtitle}>Build applications for your workflows, publish them for your users, and open the tools installed for your workspace.</p>
|
||||
<div className={styles.cardGrid}>{CARDS.map(card => <button type="button" key={card.id} className={styles.card} onClick={() => onNavigate(card.id)}>
|
||||
<span className={styles.cardTitle}>{card.title}</span><span className={styles.cardBody}>{card.body}</span>
|
||||
</button>)}</div>
|
||||
<IncludedApps showConfiguration />
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default WelcomePanel;
|
||||
|
||||
@ -83,7 +83,7 @@ export type ProjectContextValue = {
|
||||
// Operations
|
||||
newProject: (name?: string) => void;
|
||||
saveProject: () => Promise<void>;
|
||||
loadProject: (id: number) => Promise<void>;
|
||||
loadProject: (id: number) => Promise<boolean>;
|
||||
refreshProjectList: () => Promise<void>;
|
||||
|
||||
// Toast notifications
|
||||
@ -263,9 +263,11 @@ export function ProjectProvider({
|
||||
setProjectNameState(row.name);
|
||||
setIsDirty(false);
|
||||
addToast('success', `Loaded "${row.name}".`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
addToast('error', `Load failed: ${message}`);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@ -242,7 +242,7 @@ export type RestAction = {
|
||||
/**
|
||||
* @deprecated Use `project.bindings` for response data movement.
|
||||
* This field is retained for backward compatibility but is not executed
|
||||
* by the Preview runtime or backend proxy. See docs/response-mapping-model.md.
|
||||
* by the Preview runtime or backend proxy. See docs/SCHEMA.md.
|
||||
*/
|
||||
responseMapping?: ResponseMappingRule[];
|
||||
};
|
||||
|
||||