diff --git a/.agents/rules/consolebook.md b/.agents/rules/consolebook.md index a5b41c0..22e1cdb 100644 --- a/.agents/rules/consolebook.md +++ b/.agents/rules/consolebook.md @@ -1,6 +1,5 @@ # Consolebook Workspace Rule Read and follow the repository-root `AGENTS.md` before changing this -project. It and this rule contain no independent project policy; -`CONTRIBUTING.md` owns the contribution lifecycle and `docs/decisions/` -owns durable decisions. +project. It is the shared project contract and routes to additional context; +this rule adds no independent policy. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d9bf485..a12280e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -25,9 +25,12 @@ What problem does this solve, and what should be true after merge? - [ ] All fixtures and examples are invented; no real agency data ```text +- web/: npm ci, npm run check, npm run build - cargo fmt --check - cargo clippy --workspace --all-targets -- -D warnings - cargo test --workspace +- cargo build -p consolebook-server +- web/: npm run e2e (state the browser used) ``` ## Review And Merge Notes diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index a9e0d81..97b4afe 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -59,3 +59,12 @@ jobs: run: cargo clippy --workspace --all-targets -- -D warnings - name: Test run: cargo test --workspace + # Playwright starts the binary itself, using invented scratch installations. + - name: Build browser-test binary + run: cargo build -p consolebook-server + - name: Install browser + run: npx playwright install --with-deps chromium + working-directory: web + - name: Browser tests + run: npm run e2e + working-directory: web diff --git a/AGENTS.md b/AGENTS.md index bd11143..0e5cb41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,121 +1,81 @@ # Repository Guidelines -## Start With The Smallest Useful Context - -Consolebook is a small Rust workspace plus one web app: the server lives -in `crates/consolebook-server/` (library modules plus a thin CLI in -`main.rs`, integration tests in `tests/`, embedded migrations in -`migrations/`), and the embedded SvelteKit interface lives in `web/` -(built statically, embedded by the Rust build; Node.js is build-time -only). - -Durable truth lives in a few files; read only what the task needs: - -- `PRINCIPLES.md` — non-negotiable product constraints -- `docs/architecture.md` — system boundaries and design targets -- `docs/domain-model.md` — domain vocabulary and invariants -- `docs/records-integrity.md` — immutability, hashes, provenance -- `docs/roadmap.md` — milestone sequence and current position -- `docs/decisions/` — architecture decision records - -## Build And Verification - -- `cargo build -p consolebook-server` builds the server. -- `cargo test --workspace` runs the tests. -- `cargo fmt --check` and - `cargo clippy --workspace --all-targets -- -D warnings` are repository - gates; clippy pedantic is enabled workspace-wide. -- In `web/`: `npm ci`, `npm run check`, and `npm run build` are gates; - build `web/` before cargo when interface behavior matters (a bare cargo - build compiles but serves an honest "not embedded" notice). -- `npm run e2e` in `web/` drives the compiled binary through the shell in - a real browser (set `CONSOLEBOOK_E2E_CHROMIUM` to a Chromium path when - Playwright's own download is unavailable). -- The toolchain is pinned in `rust-toolchain.toml`; do not float it. - -Verification means the reported command actually ran. Preserve exact failure -evidence and explain the causal leaf failure. A design document is not runtime -proof, and a passing unit test is not recovery proof. - -## Issue, Branch, And Pull-Request Workflow - -Follow `CONTRIBUTING.md`. Non-trivial implementation, bug, refactor, -documentation, operations, and maintenance work uses one primary GitHub -issue, an issue-linked branch, and a pull request; never push repository -changes directly to `main`. Search existing issues first. `Closes #...` -means the PR satisfies the issue's acceptance criteria; otherwise use -`Refs #...` and leave the issue open. - -Security reports use private advisories per `SECURITY.md`, never public -issues. - -## Product And Authority Contract - -Consolebook is pre-alpha training-record software for emergency -communications centers. `PRINCIPLES.md` is the authority; the load-bearing -consequences for code: - -- Finalized records are immutable while retained. Corrections are successor - versions or amendments; deletion is lawful disposition, a separate - authorized workflow. Never an in-place edit. -- Agency variation is versioned configuration, never agency-specific code - paths or hidden conditionals. -- Finalized records pin the exact configuration versions used to create - them; mutable reference data must not rewrite history. -- Operational dates carry agency-local meaning; duration and ordering use - UTC instants. Do not conflate them. -- One executable, one data directory, SQLite. No required external - services, telemetry, or cloud dependencies. - -Engineer solutions, not band-aids. Heuristics, regexes, substring matching, -and silent defaults may aid diagnostics, discovery, or presentation; they -may not establish record integrity, authorization, retention, disposition, -or export behavior. Those are typed contracts with tests. A failure in a -typed check is a defect to engineer, not a class of input to route around. - -## Defect And Maintainability Discipline - -- Fix a defect, duplicated authority, or half-implementation found in - scope. File an exact-evidence issue when it belongs elsewhere; do not - silently route around it. -- Fix causes and prove the contract or property, not only the observed - input. -- Treat intermittent or unexplained failures as evidence of a defect, not - as a reason to retry until green. -- A slice adding behavior to a Rust source file over 1,000 lines must - include an ownership-based reorganization in the same issue or plan. - Thin dispatch, registration, and re-export wiring may remain in a large - hub. -- Before changing behavior in a Rust file over 1,500 lines, name the - ownership boundary being preserved or improved. Files over 2,500 lines - need a reviewed decomposition path before major feature work unless the - fix is urgent. -- Refactors name what moves, its new owner, persisted/public impact, and - the focused proof. -- Decisions that change durable system behavior get an ADR in - `docs/decisions/`; changes to `PRINCIPLES.md` require one. - -## Rust And CLI Conventions - -Use standard Rust formatting and naming, `thiserror` for library errors as -they emerge, and `anyhow` at application boundaries. `unsafe` is forbidden -workspace-wide. Keep modules focused on one domain capability. Use short -imperative Conventional Commit subjects such as -`storage(backup): validate snapshot before fsync`. - -Structured `tracing` logs never contain record content, personal data, or -credentials. Startup verifies database connection invariants and fails -closed; `doctor` diagnoses read-only and never creates or migrates state -(ADR 0003). - -## Documentation And Safety - -`AGENTS.md` is the concise repo-wide contract; `CONTRIBUTING.md` owns the -full contribution lifecycle; ADRs own decisions. Tool entrypoints -(`CLAUDE.md`, `.agents/rules/`, `.github/copilot-instructions.md`) stay -thin and point back here. - -All fixtures, examples, tests, and seed data use invented agencies, people, -incidents, narratives, and identifiers. Real training records, operational -material, credentials, or personal information never enter this repository -in any form — code, docs, tests, logs, or issue text. +Consolebook is pre-alpha training-record software for emergency communications +centers: one Rust executable, one SQLite data directory, and a static SvelteKit +UI. No required external services, telemetry, or production Node.js runtime. + +## Find the relevant context + +Start with `git status --short --branch` and the task's issue or PR, including +unresolved review feedback. Preserve unrelated work. Do not assume a previous +session's branch, milestone status, or verification still describes the head. + +Read only the context needed for the task: + +- Build, source ownership, or local development: `docs/development.md`. +- Next work and milestone status: `docs/roadmap.md`, then the linked GitHub issue. +- Product constraints: `PRINCIPLES.md`; boundaries: `docs/architecture.md`. +- Domain terms: `docs/domain-model.md`; record integrity: `docs/records-integrity.md`. +- Contribution lifecycle, verification, and refactor rules: `CONTRIBUTING.md`. +- Decisions and formats: the task index in `docs/development.md` links the + relevant ADRs and specifications. Do not load the entire corpus by default. +- Preview deployment: `docs/preview.md`. Host configuration and deployed + binaries are separate from this checkout. + +The server is in `crates/consolebook-server/`: `src/` owns services and the thin +CLI, `migrations/` owns persisted constraints, and `tests/` owns integration +proof. `web/src/` owns the UI and typed API client; `web/e2e/` tests the binary +through a browser. `sessions.rs` means login sessions; `training_sessions.rs` +means training periods. + +## Non-negotiable contracts + +- Finalized records are immutable while retained. Corrections create successor + versions or amendments. Lawful disposition is a separate authorized workflow. +- Agency variation is versioned configuration. Finalized records pin configuration + and presentation snapshots; mutable reference data must not rewrite history. +- Operational dates are agency-local; duration and ordering use UTC instants. +- Capabilities and scope are enforced by domain services. HTTP and UI adapt + those decisions. Authorization, integrity, retention, disposition, and exports + require typed contracts and tests; heuristics cannot establish them. +- Startup verifies storage invariants and fails closed. `doctor` must diagnose + without creating, migrating, or changing state (ADR 0003). +- Use invented agencies, people, incidents, identifiers, and narratives. Real + records, operational material, personal data, and credentials never enter the + repository, tests, logs, or issues. Security reports follow `SECURITY.md` privately. + +## Work and verification + +Non-trivial work requires one primary issue, an issue-linked branch, and a PR. +Search existing issues first; never push directly to `main`. `Closes #...` means +all acceptance criteria are satisfied; otherwise use `Refs #...`. + +Build `web/` before Rust when UI or embedding matters. The command sequence and +browser prerequisites are in `CONTRIBUTING.md`. Required checks: `npm ci`, +`npm run check`, `npm run build` in `web/`; `cargo fmt --check`, +`cargo clippy --workspace --all-targets -- -D warnings`, and +`cargo test --workspace`. Browser tests need the compiled debug binary. +Keep the pinned Rust toolchain in `rust-toolchain.toml`. + +Report only verification actually run, with exact failures. Investigate +intermittent failures. Design documents are not runtime proof; unit tests are +not recovery drills. Fix causes and in-scope defects; file exact-evidence +issues for separate work. + +Follow `CONTRIBUTING.md` before adding behavior to large Rust modules: over +1,000 lines requires an ownership-based reorganization, over 1,500 requires +naming the boundary first, and over 2,500 requires a reviewed decomposition +path before major feature work unless urgent. Refactors state the new owner, +persisted/public impact, and focused proof. + +Durable behavior changes require an ADR; changes to `PRINCIPLES.md` require one. +Use forward migrations, standard Rust formatting, `thiserror` for library +errors as they emerge, `anyhow` at application boundaries, and no `unsafe`. +Use short imperative Conventional Commit subjects. Logs exclude sensitive +content; existing first-run setup-code output is the documented exception +(ADR 0004), not permission for additional secret logging. + +This file owns the concise shared contract; `CONTRIBUTING.md` owns workflow, +`docs/development.md` owns the source map, and ADRs own decisions. Tool-specific +entrypoints (`CLAUDE.md`, `.agents/rules/`, `.github/copilot-instructions.md`) +only point here. Keep machine preferences and session handoffs out of this file. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14c54aa..9e8f904 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,8 @@ # Contributing -Consolebook is pre-alpha. Design changes are welcome, but the domain and record-integrity constraints in [PRINCIPLES.md](PRINCIPLES.md) come first. - -## Before contributing - -- Read the principles and relevant architecture decision records. -- Discuss broad domain or architecture changes before writing a large patch. -- Add or update an ADR when a decision changes durable system behavior. -- Keep changes narrow enough to review and verify. +Consolebook is pre-alpha. Follow [AGENTS.md](AGENTS.md) and the product +constraints in [PRINCIPLES.md](PRINCIPLES.md). The task index and source map in +[docs/development.md](docs/development.md) route to additional context. ## Issue, branch, and pull-request workflow @@ -15,53 +10,89 @@ Non-trivial implementation, bug, refactor, documentation, operations, and maintenance work uses one primary GitHub issue, an issue-linked branch, and a pull request. Never push repository changes directly to `main`. +- Inspect the checkout and existing PR reviews; preserve unrelated edits. - Search open and closed issues before filing a new one. -- `Closes #...` in a pull request means it satisfies the issue's acceptance - criteria; use `Refs #...` when the issue must remain open across slices. -- The `pr-gate` workflow enforces the Rust checks below on every pull - request; run them locally first. -- Use short imperative Conventional Commit subjects such as +- Discuss broad domain or architecture changes before writing a large patch. +- `Closes #...` means the PR satisfies the issue's acceptance criteria; use + `Refs #...` for a slice that leaves the issue open. +- Use short imperative Conventional Commit subjects, such as `storage(backup): validate snapshot before fsync`. -- Security reports use private advisories per [SECURITY.md](SECURITY.md), - never public issues. +- Security reports use private advisories per [SECURITY.md](SECURITY.md). ## Engineering discipline -Engineer solutions, not band-aids. Heuristics, regexes, substring matching, -and silent defaults may aid diagnostics, discovery, or presentation; they may -not establish record integrity, authorization, retention, disposition, or -export behavior. Fix a defect found in scope, or file an exact-evidence issue -when it belongs elsewhere; do not silently route around it. - -## Privacy and fixtures +Fix causes and prove the contract, including in-scope defects and duplicated +authority. File exact-evidence issues for separate work. Heuristics may assist +discovery or presentation; authorization, integrity, retention, disposition, +and exports require typed contracts. Investigate intermittent failures rather +than retrying until green. -Never submit real training records or operational material. +Keep modules focused on a domain capability. For Rust source files: -Examples, screenshots, tests, and seed data must use invented: +- Adding behavior to a file over 1,000 lines requires an ownership-based + reorganization in the same issue or plan. Thin dispatch, registration, and + re-export wiring may remain in a large hub. +- Before changing behavior in a file over 1,500 lines, name the ownership + boundary being preserved or improved. +- Files over 2,500 lines require a reviewed decomposition path before major + feature work unless the fix is urgent. +- Refactors name what moves, its new owner, persisted/public impact, and the + focused proof. Avoid unrelated rewrites. -- agencies; -- people and identifiers; -- incidents and addresses; -- narratives; -- schedules; and -- program content. +Decisions changing durable behavior get an ADR in `docs/decisions/`; changes +to `PRINCIPLES.md` require one. Schema changes use forward migrations. Portable +format changes update the specification, producer, verifier, and fixtures +together, with versioning governed by that format's contract. -Remove credentials and personal information from logs and bug reports. +## Build and verification -## Rust checks +Use the Rust toolchain pinned in `rust-toolchain.toml` and npm's committed +lockfile. The Vite 7 dependency declares Node.js `^20.19.0 || >=22.12.0`; +use a compatible supported Node.js release. Node.js is build-time only. -Once implementation begins, changes should pass: +From the repository root, run in this order: ```sh +(cd web && npm ci && npm run check && npm run build) cargo fmt --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace +cargo build -p consolebook-server +(cd web && npx playwright install chromium && npm run e2e) ``` -## Documentation +The explicit build supplies `target/debug/consolebook-server` for the browser +tests. The shared fixture starts a scratch installation per test on an +OS-assigned loopback port and waits for shutdown before deleting its data. +`npm run check` checks both Svelte code and browser-test TypeScript. +If a system Chromium is available, skip the browser download and run: + +```sh +(cd web && CONSOLEBOOK_E2E_CHROMIUM=/path/to/chromium npm run e2e) +``` + +Linux browser dependencies can be installed with +`npx playwright install --with-deps chromium` from `web/`. +The [pr-gate workflow](.github/workflows/pr-gate.yml) runs the web, Rust, and +browser checks on pull requests. Focused local tests help development but do +not replace required gates. Preserve exact failure evidence and report only +commands actually run; an unexplained failure is not a reason to retry. + +## Documentation and fixtures + +State what is implemented, what is proposed, and what was verified. Link to +the owning specification or decision instead of duplicating it. Update the +roadmap when milestone state changes and the development map when ownership +moves. Do not put session transcripts, transient branch inventories, or +verification claims without a named revision into agent entrypoints. -State what is implemented, what is proposed, and what has been verified. A design document is not runtime proof, and a passing unit test is not recovery proof. +All fixtures, screenshots, examples, and seed data use invented agencies, +people, incidents, identifiers, narratives, and schedules. Real operational +material and credentials never enter the repository or public issue text. +Remove sensitive values from diagnostic output before sharing it. ## Licensing contributions -Consolebook is licensed under AGPL-3.0-only. By submitting a contribution, you agree to license it under the same terms. The project does not require a separate contributor license agreement or a broad relicensing grant. +Consolebook is licensed under AGPL-3.0-only. Contributions use the same terms. +The project requires neither a separate contributor license agreement nor a +broad relicensing grant. diff --git a/README.md b/README.md index 0a3fb46..be8cb7b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,10 @@ Consolebook is an early-stage training-record system for emergency communication Consolebook is a [Fieldmouse Works](https://github.com/FieldmouseWorks) project — open tools for closed systems. -> **Status:** pre-alpha design and scaffolding. There is no usable application yet. Do not put production or personnel data into this repository or any current build. +> **Status:** pre-alpha, implemented through Milestone 5's structured-export +> and trainee-packet slices. The application is usable for development and +> evaluation, not production. Do not put production or personnel data into +> this repository or any current build. ## Product goals @@ -28,25 +31,34 @@ Consolebook is a [Fieldmouse Works](https://github.com/FieldmouseWorks) project ## Architecture baseline -The current design target is a modular monolith: +The implementation is a modular monolith: - Rust and Axum for the application and HTTP API - SQLx with SQLite in WAL mode for storage - a statically built SvelteKit interface embedded in the executable -- Typst for deterministic PDF output - opaque server-side sessions with Argon2id password hashes - one executable and one data directory per installation -The server crate implements the Milestone 1 operable shell — a CLI, SQLite storage with verified connection invariants and embedded migrations, health and diagnostics, automatic validated backups with retention and a tested restore path, local authentication and recovery, an embedded SvelteKit shell, and persisted in-app notices — plus the Milestone 2 training-configuration domain: versioned programs whose published versions are immutable at the database, a structured authoring interface with version compare, a documented byte-stable export/import format, external standards citations, minimal user creation, and enrollments that pin published versions. Training sessions and evaluation records arrive with Milestone 3. +The current build includes the operable shell and versioned program +configuration from Milestones 1 and 2; training sessions, collaborative drafts, +review, and assignment-scoped access from Milestone 3; immutable finalized +records, acknowledgments, amendments, weekly summaries, task signoffs, and the +trainee timeline from Milestone 4; and Milestone 5's file-verifiable structured +record exports and complete trainee packets. Retention, holds, lawful +disposition, attachments, Typst PDF rendering, and stronger restore proof remain +Milestone 5 work. For the live demonstration deployment, see the +[preview runbook](docs/preview.md); local builds do not update that service. ## Repository map - [PRINCIPLES.md](PRINCIPLES.md) — non-negotiable product constraints - [AGENTS.md](AGENTS.md) — repo-wide contract for contributors and agents -- [docs/architecture.md](docs/architecture.md) — proposed system boundaries +- [docs/architecture.md](docs/architecture.md) — implemented boundaries and remaining design targets - [docs/domain-model.md](docs/domain-model.md) — domain vocabulary and invariants - [docs/records-integrity.md](docs/records-integrity.md) — immutability, hashes, and provenance +- [docs/development.md](docs/development.md) — runtime flow, source ownership, and local workflow - [docs/roadmap.md](docs/roadmap.md) — milestone sequence +- [CONTRIBUTING.md](CONTRIBUTING.md) — contribution lifecycle and verification commands - [docs/decisions/](docs/decisions/) — architecture decision records - [crates/consolebook-server/](crates/consolebook-server/) — the server crate @@ -54,12 +66,13 @@ The server crate implements the Milestone 1 operable shell — a CLI, SQLite sto ```sh (cd web && npm ci && npm run build) # build the interface (embedded by cargo) -cargo run -p consolebook-server -- serve # initialize ./data and serve UI + API -cargo run -p consolebook-server -- doctor # diagnose an installation, read-only -cargo run -p consolebook-server -- backup # validated snapshot into ./data/backups +cargo run -p consolebook-server -- serve # initialize ./data and serve UI + API +cargo run -p consolebook-server -- doctor # diagnostics; see the current caveat below +cargo run -p consolebook-server -- backup # validated snapshot into ./data/backups cargo run -p consolebook-server -- restore # recover from a snapshot (server stopped) -cargo run -p consolebook-server -- setup-code # fresh first-run setup code +cargo run -p consolebook-server -- setup-code # fresh first-run setup code cargo run -p consolebook-server -- recover --username ... # rescue a locked-out administrator +cargo run -p consolebook-server -- export verify # verify an export without an installation ``` `serve` binds `127.0.0.1:7770` by default and serves the web interface and @@ -73,6 +86,11 @@ durability, [ADR 0004](docs/decisions/0004-local-authentication.md) for authentication, and [ADR 0005](docs/decisions/0005-embedded-web-interface.md) for the embedded interface. +`doctor` currently has a read-only-contract defect: it may change a non-WAL +database to WAL before reporting its settings. See +[#56](https://github.com/FieldmouseWorks/consolebook/issues/56) before using it +on retained data. + ## Privacy Examples and test fixtures must be invented. Do not commit real agency names, employee information, operational narratives, credentials, exports, screenshots, or training records. diff --git a/docs/architecture.md b/docs/architecture.md index 78cd72e..066267c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,8 @@ # Architecture -This document describes the current design target. It is not an implementation receipt. +This document describes the implemented system boundary and the remaining +design target. It is not an implementation receipt; tests and operator drills +prove runtime claims. ## Shape @@ -19,7 +21,7 @@ HTTP API and application services +-- sessions and evaluation workflow +-- immutable record versions +-- acknowledgments and amendments - +-- holds, retention, and lawful disposition + +-- holds, retention, and lawful disposition (planned) +-- authorization and audit +-- in-app notifications +-- exports and recovery @@ -30,17 +32,21 @@ SQLite database and local data directory The application should remain useful without Redis, a message broker, a Node.js runtime, a hosted identity provider, or a network connection to the project maintainers. -## Planned components +## Implemented components ### Application -Rust owns the process lifecycle, configuration, HTTP API, migrations, background maintenance, backups, exports, and embedded assets. +Rust owns the process lifecycle, configuration, HTTP API, migrations, +background maintenance, backups, exports, and embedded assets. -Axum is the planned HTTP framework. Application boundaries should follow domain capabilities rather than mirror web routes. +Axum serves versionless `/api/` routes and the embedded interface from one +listener. Application boundaries follow domain capabilities rather than mirror +web routes: handlers translate HTTP and services own policy. The detailed +source map lives in `docs/development.md`. ### Storage -SQLite is the default operational database. +SQLite is the operational database. Connections must be created from one explicit options object that enables and verifies: @@ -50,23 +56,36 @@ Connections must be created from one explicit options object that enables and ve - a bounded busy timeout; and - application-owned migrations. -Startup and the future `consolebook doctor` command will verify these invariants. +Startup verifies these invariants and fails closed. ADR 0003 requires +`consolebook doctor` to inspect without changing state. It does not create or +migrate a database, but its current connection path can change a non-WAL +database's journal mode; [#56](https://github.com/FieldmouseWorks/consolebook/issues/56) +tracks restoring the read-only contract. ### User interface -The planned interface is a statically built SvelteKit application embedded in the Rust executable. Server-side rendering and a production Node.js runtime are outside the design. +The interface is a statically built SvelteKit single-page application embedded +in the Rust executable. Server-side rendering and a production Node.js runtime +are outside the design (ADR 0005). The web interface is part of every vertical slice, not a post-API decoration. Setup, program configuration, training workflow, trainee review, retention administration, and recovery each require a usable interface before their milestone is complete. -### Documents +### Records and portable documents -Typst is the planned renderer for stable PDF exports. Templates and redistribution-friendly fonts will ship with the application. +Finalization stores versioned canonical JSON bytes and SHA-256 content and chain +hashes. Acknowledgments, amendments, weekly summaries, and task signoffs remain +separate typed history. Structured record exports and trainee packets carry +stored bytes verbatim and verify from the archive alone (ADRs 0011–0015). + +Typst is the planned renderer for stable PDF presentations. Templates and +redistribution-friendly fonts will ship with the application in a later +Milestone 5 slice. A PDF is a presentation of a record version. The structured record remains independently exportable. ## Data directory -The intended layout is deliberately boring: +The layout is deliberately boring: ```text data/ @@ -76,19 +95,25 @@ data/ └── instance/ ``` -Exact paths and retention policies remain undecided. +`DataDir` owns these paths. SQLite and application state live under this one +root; runtime services do not require an external database, queue, cache, or +object store. Retention policies for records remain Milestone 5 work. ## Backups -Backups will be automatic and default-on. +Backups are automatic and default-on while the server runs. -The current design preference is a consistent SQLite snapshot produced with `VACUUM INTO`, followed by integrity validation, an explicit durability step, and retention management. Restore must be a tested product workflow. +The implemented pipeline produces a consistent SQLite snapshot with `VACUUM +INTO`, validates it, performs an explicit durability step, and prunes by +configured count (ADRs 0003 and 0006). Manual backup and stopped-server restore +use the same library paths as the CLI. Count-plus-age retention and clean-room +restore verification remain Milestone 5 work. ## Authentication -Milestone one targets local authentication: +Local authentication implements: -- username or email; +- username; - Argon2id password hashes; - cryptographically random opaque session tokens; - HttpOnly cookies; @@ -99,7 +124,9 @@ Password recovery in v1 is local and administrator-operated: - an authorized administrator can issue a short-lived, single-use reset code; - using the code forces a new password, revokes existing sessions, and creates an audit event; and -- a sole-administrator recovery command requires operating-system access to the installation data directory and records an explicit recovery event. +- a recovery command for administrator accounts requires operating-system + access to the data directory and records an explicit recovery event. It is + not restricted to installations with only one administrator. Password reset does not depend on email or another external service. @@ -109,11 +136,16 @@ OIDC may be added behind an authentication-provider boundary later. Roles are convenient bundles of capabilities. Domain services authorize capabilities and assignment scope rather than scattering role-name comparisons. -The initial vocabulary is expected to include Administrator, Coordinator, Trainer, and Trainee, but the capability model is authoritative. +The initial roles are Administrator, Coordinator, Trainer, and Trainee, but the +capability model is authoritative. Services combine capabilities with +assignment, session-membership, or own-record scope as the operation requires +(ADR 0010). ## First-run setup -An uninitialized installation will emit a short-lived setup code. Creating the first agency settings and administrator must be a single transaction that invalidates the setup code. +An uninitialized installation emits a short-lived setup code. Creating the +first agency settings and administrator is one transaction that invalidates +the setup code. After initialization, the setup operation is unavailable. @@ -125,7 +157,12 @@ SMTP may be added later as an optional delivery adapter. It mirrors an in-app no ## Retention and disposition -Retention policy, record holds, and lawful disposition belong to application services with explicit capabilities and audit events. Normal repository methods cannot delete finalized content. A separate disposition path checks applicable policy and holds, previews scope, records authority, and removes only the approved material. +Retention policy, record holds, and lawful disposition are the next Milestone 5 +slice. They belong to application services with explicit capabilities and +audit events. Normal service methods and database triggers already reject +mutation or deletion of finalized content. The separate disposition path will +check applicable policy and holds, preview scope, record authority, and remove +only the approved material. Disposition records have retention rules of their own. The architecture must not keep personal metadata forever merely to make an integrity chain convenient. diff --git a/docs/audits/2026-09-05.md b/docs/audits/2026-09-05.md new file mode 100644 index 0000000..0ce7f32 --- /dev/null +++ b/docs/audits/2026-09-05.md @@ -0,0 +1,90 @@ +# Continuation audit — 2026-09-05 + +Reviewed implementation baseline: `a85b909` (main), with the documentation +refresh at `f369981` on PR #55. This report accompanies the continuation of +[#53](https://github.com/FieldmouseWorks/consolebook/issues/53). +It is dated evidence, not the current milestone authority; use +[roadmap.md](../roadmap.md) and live issue/PR state for new work. + +## Scope and result + +Inspected repository and agent entrypoints, build/CI configuration, CLI and +storage paths, service/HTTP/UI ownership, canonical/export/packet boundaries, +migrations, representative tests, review feedback, and the preview host's +service/listener. Ran the existing web, Rust, and browser checks. This was a +continuation-readiness audit, not a comprehensive security review, an +exhaustive line-by-line review, or a production recovery drill. + +The existing architecture supports further development. The next planned +feature is Milestone 5 slice 3 under #44. Known concurrency, verifier, and +diagnostic defects remain; passing the current suite does not close them. + +## Changes from this audit + +- Reduced the default AGENTS.md context and added explicit task routing. + Kept product invariants and the large-file rules; moved workflow detail to + CONTRIBUTING.md. Fixed the contradictory tool rule claiming AGENTS.md had + no independent project policy. +- Replaced the development inventory with a task/source/ADR/format index, + explicit web ownership, and local-versus-deployed build behavior. Removed + duplicated command lists and editorial filler. +- Corrected the PR #55 review finding: only the packet path is documented as + providing snapshot-bound authorization, not every service. +- Clarified draft versus published configuration, missing attachments/PDFs, + administrator recovery scope, and the diagnostic contract gap below. +- Added the existing five browser scenarios to CI after an explicit binary + build. Previously CI only ran web check/build and Rust gates. +- Consolidated repeated browser process management into one fixture with + ephemeral ports, owned readiness, awaited shutdown, and scratch cleanup. + Added three lifecycle checks and included browser TypeScript in the check + gate, with the missing Node type dependency declared explicitly. +- Added a preview runbook describing the independent release installation, + access boundary, manual updates, and host-wide certificate renewal hooks. + +## Findings and next work + +| Finding | Evidence and disposition | +| --- | --- | +| Doctor changes the database it diagnoses | A temporary DELETE-mode SQLite database became WAL and changed SHA-256 after `doctor`; it reported WAL as healthy. `doctor::run` uses the writable `storage::open_existing` options. Reproduction and acceptance criteria: [#56](https://github.com/FieldmouseWorks/consolebook/issues/56). Separate diagnostic/storage repair; docs now qualify the implementation. | +| Earlier write paths use deferred transactions | `programs.rs` and `training_sessions.rs` still call `pool.begin()` for writes; the immediate-transaction retrofit is [#27](https://github.com/FieldmouseWorks/consolebook/issues/27). Prioritize relevant paths before building more concurrent workflows on them. | +| Packet pin history does not establish all timeline rules | [#52](https://github.com/FieldmouseWorks/consolebook/issues/52) records the missing verifier rules; [#51](https://github.com/FieldmouseWorks/consolebook/issues/51) records the looser enrollment-event CHECK. Address these before extending packet lineage for disposition. | +| Browser harness duplicated lifecycle ownership | Five specs repeated startup/polling/teardown, ignored child output, and used fixed ports. Replaced by the shared fixture in this PR, addressing [#57](https://github.com/FieldmouseWorks/consolebook/issues/57). Tests cover missing executables, early exit, occupied ports, distinct listeners, and awaited shutdown/cleanup. | +| Large owners need scoped decomposition | `programs.rs`: 1,527 lines; `evaluation_drafts.rs`: 1,021. Before adding behavior, propose extracting configuration vocabulary/validation and persistence helpers from programs, and workspace presentation from draft workflow. Preserve public/persisted contracts and prove them with the matching integration suites. These are proposed boundaries, not a reviewed refactor plan; [#58](https://github.com/FieldmouseWorks/consolebook/issues/58) tracks the work and proof. | +| Web ownership is concentrated | Draft page: 1,585 lines; enrollment page: 1,284; API client: 1,271. Extract workflow sections and domain client groups when those owners next change, keeping the typed API boundary intact. [#59](https://github.com/FieldmouseWorks/consolebook/issues/59) tracks the boundaries and regression proof. No wholesale UI rewrite was needed for this audit. | +| Existing user-facing and scale debt | [#34](https://github.com/FieldmouseWorks/consolebook/issues/34): stale-save recovery; [#49](https://github.com/FieldmouseWorks/consolebook/issues/49): trainee signoff view; [#47](https://github.com/FieldmouseWorks/consolebook/issues/47): in-memory export assembly. | +| Existing dependency advisory | `npm ci` still reports three low-severity entries from the previously tracked SvelteKit/cookie chain, [#54](https://github.com/FieldmouseWorks/consolebook/issues/54). No dependency override or automatic forced update was applied. | + +## Verification + +Commands run successfully against the unchanged application code: + +```text +web/: npm ci +web/: npm run check 0 errors, 0 warnings +web/: npm run build +cargo fmt --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace 118 integration tests passed +cargo build -p consolebook-server +web/: CONSOLEBOOK_E2E_CHROMIUM=/usr/bin/google-chrome npm run e2e + 5 scenarios passed, 29.7 seconds +``` + +The doctor reproduction intentionally exited 1 on a toy database missing +application tables; the finding is the persisted journal-mode mutation, not +that expected missing-schema refusal. Scratch data was invented. + +The initial explicit browser TypeScript check failed with +`TS2307: Cannot find module 'node:fs/promises' or its corresponding type declarations` +and missing `process`/Node types. The old Svelte check did not cover those +files. Adding `@types/node`, `tsconfig.e2e.json`, and the TypeScript invocation +to `npm run check` fixed that gap. The check then passed; the five scenarios +and three new fixture tests passed locally after the consolidation and +diagnostic-redaction assertions (8 passed in 29.4 seconds). `npm run build` +also passed with the added development-only types dependency. + +The preview service was active and its public HTTPS endpoint returned the +expected unauthenticated `401`. Its installed binary hash matched the release +artifact from the earlier deployment. No deployment, data migration, or +credential change was made during this audit. A new CI run is the authority +for the workflow changes; the local browser run used system Chrome. diff --git a/docs/decisions/0011-canonical-record-format-and-finalization.md b/docs/decisions/0011-canonical-record-format-and-finalization.md index 1a813a0..17007b5 100644 --- a/docs/decisions/0011-canonical-record-format-and-finalization.md +++ b/docs/decisions/0011-canonical-record-format-and-finalization.md @@ -20,9 +20,11 @@ and the completion rules (#36; Milestone 4 slice 1). ### Canonical bytes - A finalized version's content is one JSON document, serialized under - RFC 8785 (JSON Canonicalization Scheme) semantics: UTF-8, object - members sorted by code point, no insignificant whitespace, JCS - string escaping. + RFC 8785 (JSON Canonicalization Scheme) semantics: UTF-8, ASCII + object-member names in sorted order, no insignificant whitespace, + JCS string escaping. Non-ASCII member names are refused by the + serializer; string values may contain Unicode. The ASCII restriction + makes byte/code-point/UTF-16 member ordering agree. - The document is restricted to a closed subset that keeps JCS number rules trivial and portable: values are objects, arrays, strings, booleans, `null`, and integers with magnitude below 2^53. Floats, diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..177d393 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,110 @@ +# Development Guide + +This map routes contributors and agents to the implementation and its +authorities. [AGENTS.md](../AGENTS.md) owns repository rules; +[CONTRIBUTING.md](../CONTRIBUTING.md) owns build gates and contribution workflow. + +## Choose context by task + +Read the relevant row, then the specific service, migration, and test involved. +ADRs record decisions; format documents specify portable bytes; source and +tests show what is implemented. [Roadmap](roadmap.md) owns milestone status. + +| Task | Start in `crates/consolebook-server/src/` | Supporting context | +| --- | --- | --- | +| Process, storage, diagnostics | `main.rs`, `data_dir.rs`, `storage.rs`, `doctor.rs` | [Architecture](architecture.md), [ADR 0003](decisions/0003-sqlite-connection-invariants.md) | +| Backups and restore | `backup.rs`, `scheduler.rs`, `restore.rs`, `serve_lock.rs` | [ADR 0006](decisions/0006-backup-scheduling-and-restore.md) | +| Setup, login, recovery | `setup.rs`, `users.rs`, `sessions.rs`, `secrets.rs` | [ADR 0004](decisions/0004-local-authentication.md) | +| Capabilities and assignments | `capabilities.rs`, `assignments.rs`, `draft_access.rs` | [ADR 0010](decisions/0010-service-owned-authorization-boundary.md), [Domain model](domain-model.md) | +| Program configuration | `programs.rs`, `program_export.rs` | [ADR 0007](decisions/0007-program-version-configuration-model.md), [Program format](formats/program-version-export.md) | +| Enrollment and training sessions | `enrollments.rs`, `lifecycle.rs`, `training_sessions.rs`, `session_membership.rs`, `session_time.rs` | [ADR 0008](decisions/0008-session-draft-and-attribution-model.md), [ADR 0009](decisions/0009-session-local-time-resolution.md) | +| Drafts and review | `evaluation_drafts.rs`, `draft_content.rs`, `draft_review.rs` | [ADR 0008](decisions/0008-session-draft-and-attribution-model.md), [ADR 0010](decisions/0010-service-owned-authorization-boundary.md) | +| Finalization and canonical bytes | `finalization.rs`, `canonical.rs`, `record_envelope.rs` | [Integrity](records-integrity.md), [ADR 0011](decisions/0011-canonical-record-format-and-finalization.md) | +| Acknowledgments and amendments | `acknowledgments.rs`, `amendments.rs` | [Domain model](domain-model.md), [ADR 0012](decisions/0012-amendment-reopening-state-machine.md) | +| Summaries and signoffs | `summaries.rs`, `task_signoffs.rs` | [ADR 0013](decisions/0013-weekly-summaries-and-task-signoffs.md) | +| Record exports | `record_export.rs`, `export_verify.rs`, `zip_container.rs` | [ADR 0014](decisions/0014-record-export-format.md), [Export format](formats/record-export.md) | +| Trainee packets | `trainee_packet.rs`, `packet_verify.rs` | [ADR 0015](decisions/0015-trainee-packet.md), [Packet format](formats/trainee-packet.md) | +| Retention, holds, disposition (planned) | No implemented service yet | [Integrity](records-integrity.md), [Milestone 5 decisions](https://github.com/FieldmouseWorks/consolebook/issues/44) | +| Web shell and HTTP | `http.rs`, `web_assets.rs`, `notices.rs`, domain `*_http.rs` modules | [ADR 0005](decisions/0005-embedded-web-interface.md), web map below | +| Preview operations | Separate host installation | [Preview runbook](preview.md) | + +`lib.rs` exposes the library modules. Integration tests in +`crates/consolebook-server/tests/` are named by capability; migration files in +`crates/consolebook-server/migrations/` own schema, constraints, and triggers. +Read both when changing a persisted contract. + +## Runtime flow + +```text +Svelte route -> web/src/lib/api.ts -> /api/* HTTP adapter + -> domain service -> SQLx -> SQLite constraints and triggers +``` + +`main.rs` owns the CLI. `serve` resolves the data directory, acquires the serve +lock, opens and migrates SQLite, verifies connection invariants, starts the +backup scheduler, and serves Axum. `http.rs` owns router registration, the +current-user extractor, and error translation. Larger handler groups live in +`programs_http.rs`, `training_http.rs`, `drafts_http.rs`, and `exports_http.rs`. +Policy belongs in services; persisted constraints also have database backstops. +`audit.rs` owns typed audit events; `notices.rs` owns recipient-scoped notices. + +`sessions.rs` owns login sessions; `training_sessions.rs` owns periods of +training. Do not infer policy from a role name or a UI guard. + +New write paths use `storage::write_tx` and await rollback on refusal through +`storage::refuse`. Earlier deferred write transactions still need the +[#27 retrofit](https://github.com/FieldmouseWorks/consolebook/issues/27). +A transaction's presence alone does not prove authorization shares its +snapshot; check where the decision is evaluated. See the +[domain-model qualification](domain-model.md#application-service-invariants). + +## Web map + +The UI is a client-routed SPA. `web/src/routes/+layout.ts` guards setup and +authentication; `+layout.svelte` owns navigation and shared styling. +`web/src/lib/api.ts` owns typed same-origin HTTP calls. +`web/src/lib/editor/` contains program-authoring components. + +`web/e2e/fixtures.ts` supplies each scenario's server, base URL, and setup code. +`server.ts` owns process startup and scratch-data cleanup; `server.spec.ts` +checks startup failures, listener ownership, and shutdown. Keep scenario data +and assertions in their own specs. + +| Route | Ownership | +| --- | --- | +| `/setup`, `/login`, `/reset` | Installation and authentication entry | +| `/` | Capability-sensitive status, notices, administration, session/review queues, installation exports | +| `/programs/**` | Program authoring, comparison, publishing, enrollment | +| `/enrollments/[id]` | Lifecycle, assignments, sessions, summaries, signoffs, exports | +| `/drafts/[id]` | Authoring, review, finalized presentation, acknowledgment, amendments | +| `/records` | Trainee's own timeline and packet downloads | + +## Local workflow + +From the repository root: + +```sh +(cd web && npm ci && npm run build) +cargo run -p consolebook-server -- --data-dir ./data serve +``` + +Open on the same machine. For a fresh installation, +use the setup code printed by the server, create invented agency/admin data, +then sign in. Use a separate empty data directory for a disposable preview. +The published preview already occupies port 7770 on its host; choose another +`serve --bind 127.0.0.1:PORT` there. + +For live UI editing, run `npm run dev` in `web/` with a local Rust server. +`vite.config.ts` proxies `/api` to port 7770; adjust the target when using a +different local port. Check that target before using the dev UI on a shared +host. `npm run preview` alone serves static files without that API proxy. + +`web_assets.rs` uses the build in `web/build/`: release builds embed it; +debug builds read it from disk. A missing build serves the explicit +"interface not embedded" notice. Rebuild web then Rust for release packaging. +Node.js is never required by the deployed binary. + +See [CONTRIBUTING.md](../CONTRIBUTING.md#build-and-verification) for the full +verification sequence and browser prerequisites. +`cargo run -p consolebook-server -- --help` lists CLI operations; +`export verify ` reads a file without opening an installation. diff --git a/docs/domain-model.md b/docs/domain-model.md index 95d4856..c4873f2 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -1,22 +1,27 @@ # Domain Model -Consolebook uses an opinionated training domain with versioned agency configuration. The names below are working vocabulary, not final table names. +Consolebook uses a training domain with versioned agency configuration. These +are domain concepts, not a table inventory. Retention/disposition, attachments, +and PDF presentation remain design targets; see [roadmap.md](roadmap.md). ## Configuration ### Program and ProgramVersion -A Program is the continuing identity of a training program. A ProgramVersion is an immutable configuration snapshot containing: +A Program is the continuing identity of a training program. A ProgramVersion +is editable while a draft and immutable after publication. It contains: - phase definitions and allowed transitions; - competencies and tasks; - evaluation forms; - rating scales and modifiers; - narrative requirements; -- completion rules; and -- document presentation metadata. +- completion rules. -Publishing a change creates a new ProgramVersion. Existing enrollments never float silently to it. +PDF template/font metadata remains part of the planned rendering work. + +Changes to published configuration require a new draft ProgramVersion, then +publication. Existing enrollments never float silently to it. ### EvaluationForm @@ -28,7 +33,8 @@ Daily reports, weekly summaries, and phase evaluations are distinct record types ### User -A person with a stable internal identity. Names, employee identifiers, titles, and contact details are mutable profile data. +A person with a stable internal identity. Names, employee identifiers, and +titles are profile data. Contact details are not currently modeled. Finalized records snapshot the presentation values they used. @@ -101,7 +107,7 @@ An immutable finalized snapshot containing the complete historical presentation: - program, phase, form, competency, and rating definitions; - observations, ratings, modifiers, and narratives; - covered sessions; -- attachments and their hashes; +- an attachments member, currently empty pending attachment support; - timestamps and local-time representation; - canonicalization version; and - integrity metadata. @@ -148,7 +154,7 @@ A RecordExport is an archive of finalized EvaluationVersions as stored: each ver A TraineePacket is everything retained about one enrollment as one archive (`docs/formats/trainee-packet.md`, ADR 0015): the record export's units for every retained version of every record, plus typed documents for the enrollment's lifecycle and phase history, every acknowledgment, every amendment, and the full task signoff history, named with hashes by one packet manifest. The trainee may produce their own; so may whoever reads the enrollment's training history and `export_records` holders. It verifies with the same verifier as a record export. -## Retention and disposition +## Retention and disposition (planned) ### RetentionPolicy @@ -176,13 +182,19 @@ The event does not preserve destroyed narratives, attachments, presentation snap ### AuditEvent -Security- and record-sensitive actions produce append-only audit events, including authentication, authorization changes, finalization, acknowledgment, refusal, amendment, hold changes, disposition, export, backup, and restore. +Security- and record-sensitive actions produce append-only audit events. The +implemented vocabulary covers authentication and recovery, assignments and +enrollment lifecycle, draft and review workflow, finalization, +acknowledgments, amendments, exports, and backup or restore operations. Hold +and disposition events join that vocabulary with the Milestone 5 retention +slice. An audit event supplements the immutable domain record. It is not a substitute for version history. -## Database invariants to enforce +## Database invariants -The initial schema is expected to enforce at least: +The embedded migrations, constraints, and triggers enforce the persisted +contract. Among the current invariants: 1. finalized versions cannot be updated or deleted through normal application writes; 2. acknowledgments reference a specific finalized version; @@ -193,12 +205,21 @@ The initial schema is expected to enforce at least: 7. active training intervals for one trainee cannot overlap; and 8. no uniqueness constraint assumes one session or evaluation per trainee and calendar date. -The exact enforcement mechanism—constraints, triggers, or transactional application services—will be decided with migration `0001`. +Application services add transactional checks where the invariant spans +authorization, workflow state, or several tables. Migrations are forward-only; +the migration that introduced an invariant remains its historical authority. -## Application-service invariants to enforce +## Application-service invariants 1. capability and assignment scope are checked before sensitive reads and writes; 2. `view_own_records` grants a trainee access only to their own retained timeline; -3. any applicable hold blocks disposition; -4. disposition requires explicit capability, policy authority, scope confirmation, attribution, and a recorded result; and -5. all trainers assigned to the same trainee can share the records allowed by policy without receiving broad access to unrelated trainees. +3. all trainers assigned to the same trainee can share the records allowed by policy without receiving broad access to unrelated trainees. + +Snapshot-bound authorization is implemented for trainee packets (ADR 0015), +not a universal service guarantee. Other read/write paths need their transaction +boundaries checked before claiming the same property. A write transaction alone +does not ensure an earlier authorization decision belongs to its snapshot. + +The Milestone 5 retention slice adds two further invariants: any applicable +hold blocks disposition, and disposition requires explicit capability, policy +authority, scope confirmation, attribution, and a recorded result. diff --git a/docs/preview.md b/docs/preview.md new file mode 100644 index 0000000..4df1219 --- /dev/null +++ b/docs/preview.md @@ -0,0 +1,83 @@ +# Preview deployment + +The demonstration site is . It contains +invented data and is separate from development installations. This is a host +runbook, not a production-readiness statement. Credentials belong outside the +repository; the browser first prompts for nginx Basic Auth, then Consolebook +sign-in. + +## Installed layout + +The setup recorded on 2026-09-02 uses: + +| Component | Location or behavior | +| --- | --- | +| Service | `consolebook-preview.service`, enabled at boot, restarts on failure | +| Service account | `consolebook-preview`, no interactive login | +| Release executable | `/srv/consolebook-preview/bin/consolebook` | +| Persistent data and backups | `/srv/consolebook-preview/data/` | +| App listener | `127.0.0.1:7770` | +| nginx vhost | `/etc/nginx/sites-available/consolebook-preview`, linked in `sites-enabled/` | +| TLS certificate | `/etc/letsencrypt/live/consolebook-preview.conary.io/` | + +nginx redirects HTTP to HTTPS, requires Basic Auth, proxies to the loopback +listener, adds HSTS and no-index headers, and forces `Secure` on the app session +cookie. The app itself does not yet set that attribute (ADR 0004). + +The service runs a copied release executable. Editing the checkout, rebuilding +the web app, or merging a PR does not deploy it. There is no automatic deploy +pipeline or built-in Git revision display; the reported `0.0.0` cannot identify +which commit is running. Record the source commit and executable SHA-256 with +each deployment. + +## Read-only status checks + +On the host, inspect the service and listener: + +```sh +systemctl status consolebook-preview.service --no-pager +systemctl cat consolebook-preview.service +ss -ltn | rg ':7770' +curl --fail http://127.0.0.1:7770/api/health +curl --head https://consolebook-preview.conary.io/ +``` + +An unauthenticated HTTPS request should receive `401` with a Basic Auth +challenge. A local health response should report database `ok`; neither check +proves recovery or a complete user workflow. Logs are in the system journal; +first-run logs can contain the short-lived setup code, so inspect privately +and redact before sharing. Avoid `doctor` on retained data pending +[#56](https://github.com/FieldmouseWorks/consolebook/issues/56): its current +connection path can change persisted journal mode while diagnosing it. + +## Updating the preview + +Use the issue/branch/PR workflow in [CONTRIBUTING.md](../CONTRIBUTING.md) for +deployment changes. Check the current host configuration and data state first; +this runbook does not authorize replacing or reinitializing retained data. + +1. Build and verify the chosen revision: web first, then Rust gates and browser + tests, then `cargo build --release -p consolebook-server`. +2. Take and retain a validated backup using the installed binary and service + account. Keep the old executable and record both source revision and hash. +3. Stage the new executable beside the installed one. Stop the preview service, + install the staged executable with root ownership and executable permissions, + then start the service. Startup may migrate the database. +4. Verify service state, loopback health, HTTPS authentication, and a browser + sign-in through the public hostname. Record the outcome and revision. + +An older binary is not necessarily compatible with a migrated database. +Recovery after a failed upgrade needs the saved binary and compatible snapshot; +do not assume a binary-only rollback is safe. + +## Certificate renewal + +The host uses Certbot's standalone authenticator. Existing global renewal hooks +stop nginx before renewal and start it afterward, briefly affecting all nginx +sites. A webroot challenge would fail with those hooks because its server is +stopped. Keep the authenticator and hooks consistent; do not change them for +one vhost without considering the other sites. Inspect the renewal config and +timer on the host; successful issuance alone does not prove a future renewal. + +If Cloudflare proxying is enabled, use Full (strict) TLS to the origin. The +application remains on loopback; public port 7770 is unnecessary. diff --git a/docs/records-integrity.md b/docs/records-integrity.md index f7b76e2..fb40bb2 100644 --- a/docs/records-integrity.md +++ b/docs/records-integrity.md @@ -12,34 +12,42 @@ A correction produces a successor version linked to the original with an explici ## Canonical bytes -The canonical byte representation is part of the record format and must be specified before records are produced. +The canonical byte representation is part of the record format and was fixed +before the first finalized record by ADR 0011. -The current design target is canonical JSON using RFC 8785 JSON Canonicalization Scheme semantics: +Consolebook uses canonical JSON with RFC 8785 JSON Canonicalization Scheme +semantics and a deliberately closed value subset: - UTF-8 encoding; - deterministic object-member ordering; - deterministic number serialization; -- no insignificant whitespace; and -- a versioned envelope identifying the canonicalization and record-schema versions. +- no insignificant whitespace; +- a versioned envelope identifying the canonicalization and record-schema versions; +- integers with magnitude below 2^53; and +- no floating-point values or non-ASCII object-member names. -Hashes must be calculated over the specified canonical bytes, never over incidental serializer output. +Hashes are calculated over the specified canonical bytes, never over incidental +serializer output. Golden vectors in +`crates/consolebook-server/tests/canonical_format.rs` pin the serializer. ## Stable fingerprints Every finalized version receives a SHA-256 content hash. -A version relationship may also carry an integrity-chain hash using a domain-separated construction such as: +Every version also carries the domain-separated integrity-chain hash fixed by +ADR 0011: ```text SHA-256( "consolebook-version-v1" || 0x00 || - previous_version_hash || + predecessor_content_hash || canonical_record_bytes ) ``` -Exact byte lengths and treatment of a missing predecessor must be fixed in the format specification and covered by golden vectors. +The predecessor is the prior version's raw 32-byte content hash, or 32 zero +bytes for a first version. Golden vectors cover both cases. This chain detects corruption, incomplete history, buggy writes, and lazy tampering. Someone with arbitrary database-write access can recompute a database-local chain, so the product must not describe the chain alone as strong tamper evidence. @@ -47,9 +55,12 @@ This chain detects corruption, incomplete history, buggy writes, and lazy tamper A future stronger mode may sign version hashes with an installation Ed25519 key stored outside SQLite with operating-system access controls. -The public key and signature metadata would accompany structured exports and PDFs. Key creation, rotation, backup, recovery, and compromise handling require a separate design and are outside milestone one. +The public key and signature metadata would accompany structured exports and +PDFs. Key creation, rotation, backup, recovery, and compromise handling remain +deferred pending a separate design. -Canonicalization is included now so signatures can be added without redefining a record. +Canonicalization allows signatures to be added later without redefining a +record. ## Historical presentation snapshots @@ -61,16 +72,18 @@ Finalized versions cannot depend on mutable joins for their meaning. They preser - competency and task text; - rating labels and definitions; - form instructions; -- timezone and local-time representation; and -- template and font versions used for rendered output. +- timezone and local-time representation. + +Template and font version pinning belongs to the planned PDF implementation. Stable IDs preserve identity. Snapshots preserve what the record said. ## Attachments -Attachments included in a finalized record receive cryptographic hashes and immutable metadata. Replacing an attachment creates a successor record version. - -Malware scanning, content-type validation, size limits, and export behavior remain to be designed. +The canonical envelope reserves an attachments member, currently empty. +Attachment content, cryptographic hashes, immutable metadata, replacement +behavior, malware scanning, content-type validation, size limits, and export +behavior remain Milestone 5 design and implementation work. ## Acknowledgments @@ -82,7 +95,7 @@ An amendment never inherits acknowledgment silently. Immutability governs records while they are retained. It does not overrule an approved records-retention schedule or authorize keeping personal data forever. -Consolebook will model retention as an explicit workflow: +The next Milestone 5 slice implements retention as this explicit workflow: 1. a versioned policy identifies the record class, disposition authority, trigger, retention period, and action; 2. the service checks for litigation, anticipated-litigation, audit, investigation, public-records-request, and other configured holds; @@ -104,14 +117,16 @@ Audit events and disposition events need their own retention and integrity rules ## Verification -Before the first production-capable release, Consolebook must have: +Current automated evidence includes canonicalization and hash golden vectors, +database tests proving finalized rows reject mutation, amendment and +acknowledgment binding tests, file-only structured-export and trainee-packet +verification, backup validation, and tests of the stopped-server restore path. + +Before the first production-capable release, Consolebook still needs: -- canonicalization golden vectors; -- hash and predecessor-chain vectors; -- database tests proving finalized rows reject mutation; - hold and lawful-disposition tests, including partial failure and retry behavior; - verification tests for retained tombstones and policy-required chain closure; -- export round-trip tests; - deterministic PDF fixtures within defined tolerances; -- backup validation and restore drills; and -- tests proving amendments and acknowledgments bind to exact versions. +- attachment integrity and export tests; and +- recorded clean-installation restore drills beyond the automated restore-path + tests. diff --git a/docs/roadmap.md b/docs/roadmap.md index 95a6780..90b33f1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,7 +9,17 @@ closed with #32's four slices (#36, #38, #40, #42): canonical bytes and immutable finalized versions with completion rules, acknowledgments and the trainee timeline, amendments and successor versions (ADR 0012), and weekly summaries with task signoffs (ADR 0013, record -schema 2). Milestone 5 — exports and recovery — is next. +schema 2). Milestone 5 is in progress under #44. Its first two slices are +complete: file-verifiable structured record exports (#46, ADR 0014) and +complete trainee packets (#50, ADR 0015). Slice 3 — retention policy, +holds, lawful disposition, tombstones, and explicit authority — is next. + +For continuation, start with the approved design in +[#44](https://github.com/FieldmouseWorks/consolebook/issues/44) and check the +[open issues](https://github.com/FieldmouseWorks/consolebook/issues?q=is%3Aissue+is%3Aopen) +and current PR reviews. The [2026-09-05 audit](audits/2026-09-05.md) records +verification, known gaps, and recommended sequencing at that revision; it is +not a substitute for live issue state. ## Milestone 0 — Foundation diff --git a/web/e2e/drafts.spec.ts b/web/e2e/drafts.spec.ts index 7f47f58..8194a17 100644 --- a/web/e2e/drafts.spec.ts +++ b/web/e2e/drafts.spec.ts @@ -1,53 +1,17 @@ -// Browser proof of Milestone 3 slice 3: starting the daily draft from a -// session, autosaved collaborative content with visible attribution, -// ownership transfer, and the submission that freezes the draft. All -// fixture data is invented. +// Browser proof of the record lifecycle: collaborative drafts, review, +// finalization, acknowledgment, amendments, summaries, signoffs, exports, +// and trainee packets. All fixture data is invented. -import { execFile, spawn, type ChildProcess } from 'node:child_process'; -import { mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; -import { expect, test, type Page } from '@playwright/test'; +import { expect, test } from './fixtures'; +import type { Page } from '@playwright/test'; +import { BINARY, execFileAsync } from './server'; -const execFileAsync = promisify(execFile); - -const BINARY = join(import.meta.dirname, '../../target/debug/consolebook-server'); -const BASE = 'http://127.0.0.1:7785'; const PASSWORD = 'invented-passphrase-1'; const JORDAN_PASSWORD = 'trainer-passphrase-3'; const ROWAN_PASSWORD = 'trainer-passphrase-4'; const CASEY_PASSWORD = 'coordinator-passphrase-5'; const TAYLOR_PASSWORD = 'trainee-passphrase-6'; -let server: ChildProcess; -let dataDir: string; - -test.beforeAll(async () => { - dataDir = join(mkdtempSync(join(tmpdir(), 'consolebook-e2e-')), 'data'); - server = spawn(BINARY, ['--data-dir', dataDir, 'serve', '--bind', '127.0.0.1:7785'], { - stdio: 'ignore' - }); - for (let i = 0; i < 50; i += 1) { - try { - const response = await fetch(`${BASE}/api/health`); - if (response.ok) return; - } catch { - // not up yet - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } - throw new Error('server did not become healthy'); -}); - -test.afterAll(() => { - server?.kill('SIGTERM'); -}); - -async function setupCode(): Promise { - const { stdout } = await execFileAsync(BINARY, ['--data-dir', dataDir, 'setup-code']); - return stdout.trim(); -} const content = { name: 'Example County CTO Program', @@ -135,7 +99,7 @@ const content = { }; async function resetAndLogin(page: Page, username: string, resetCode: string, password: string) { - await page.goto(`${BASE}/reset`); + await page.goto(`/reset`); await page.getByLabel('Username').fill(username); await page.getByLabel('Reset code').fill(resetCode); await page.getByLabel('New password').fill(password); @@ -146,11 +110,11 @@ async function resetAndLogin(page: Page, username: string, resetCode: string, pa await page.getByRole('button', { name: 'Sign in' }).click(); } -test('draft, collaborate, transfer, and submit a daily evaluation', async ({ page }) => { +test('draft, collaborate, transfer, and submit a daily evaluation', async ({ page, setupCode }) => { // Initialize and sign in as the administrator. - await page.goto(`${BASE}/`); + await page.goto(`/`); await expect(page).toHaveURL(/\/setup$/); - await page.getByLabel('Setup code').fill(await setupCode()); + await page.getByLabel('Setup code').fill(setupCode); await page.getByLabel('Agency name').fill('Example County Communications'); await page.getByLabel('Administrator username').fill('avery.admin'); await page.getByLabel('Administrator display name').fill('Avery Admin'); @@ -165,29 +129,29 @@ test('draft, collaborate, transfer, and submit a daily evaluation', async ({ pag // Seed the published program, people, enrollment, assignment, and an // open session over the API; the cookie rides page.request. const program = await ( - await page.request.post(`${BASE}/api/programs`, { data: { name: content.name } }) + await page.request.post(`/api/programs`, { data: { name: content.name } }) ).json(); const version = await ( - await page.request.post(`${BASE}/api/programs/${program.id}/versions`, { data: content }) + await page.request.post(`/api/programs/${program.id}/versions`, { data: content }) ).json(); - await page.request.post(`${BASE}/api/program-versions/${version.id}/publish`, { data: {} }); + await page.request.post(`/api/program-versions/${version.id}/publish`, { data: {} }); const trainee = await ( - await page.request.post(`${BASE}/api/users`, { + await page.request.post(`/api/users`, { data: { username: 'taylor.trainee', display_name: 'Taylor Trainee' } }) ).json(); const jordan = await ( - await page.request.post(`${BASE}/api/users`, { + await page.request.post(`/api/users`, { data: { username: 'jordan.trainer', display_name: 'Jordan Trainer', role: 'trainer' } }) ).json(); const rowan = await ( - await page.request.post(`${BASE}/api/users`, { + await page.request.post(`/api/users`, { data: { username: 'rowan.trainer', display_name: 'Rowan Trainer', role: 'trainer' } }) ).json(); const casey = await ( - await page.request.post(`${BASE}/api/users`, { + await page.request.post(`/api/users`, { data: { username: 'casey.coord', display_name: 'Casey Coordinator', @@ -196,14 +160,14 @@ test('draft, collaborate, transfer, and submit a daily evaluation', async ({ pag }) ).json(); const enrollment = await ( - await page.request.post(`${BASE}/api/program-versions/${version.id}/enrollments`, { + await page.request.post(`/api/program-versions/${version.id}/enrollments`, { data: { user_id: trainee.id } }) ).json(); - await page.request.post(`${BASE}/api/enrollments/${enrollment.id}/assignments`, { + await page.request.post(`/api/enrollments/${enrollment.id}/assignments`, { data: { trainer_user_id: jordan.id } }); - await page.request.post(`${BASE}/api/enrollments/${enrollment.id}/sessions`, { + await page.request.post(`/api/enrollments/${enrollment.id}/sessions`, { data: { business_date: '2026-06-02', timezone: 'America/Chicago', @@ -494,7 +458,7 @@ test('draft, collaborate, transfer, and submit a daily evaluation', async ({ pag await page.getByLabel('Password').fill(CASEY_PASSWORD); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page.getByRole('heading', { name: 'Installation status' })).toBeVisible(); - await page.goto(`${BASE}/enrollments/${enrollment.id}`); + await page.goto(`/enrollments/${enrollment.id}`); await expect(page.getByRole('heading', { name: 'Task signoffs' })).toBeVisible(); await page.getByRole('button', { name: 'Observed', exact: true }).first().click(); await expect(page.getByText('by Casey Coordinator', { exact: false }).first()).toBeVisible(); diff --git a/web/e2e/fixtures.ts b/web/e2e/fixtures.ts new file mode 100644 index 0000000..bb5d738 --- /dev/null +++ b/web/e2e/fixtures.ts @@ -0,0 +1,25 @@ +import { test as base, expect } from '@playwright/test'; +import { startServer } from './server'; + +export { expect }; + +export const test = base.extend<{ + server: Awaited>; + setupCode: string; +}>({ + server: async ({}, use) => { + const server = await startServer(); + try { + await use(server); + } finally { + // Scratch records and credentials are discarded on success and failure. + await server.stop(); + } + }, + baseURL: async ({ server }, use) => { + await use(server.url); + }, + setupCode: async ({ server }, use) => { + await use(await server.setupCode()); + } +}); diff --git a/web/e2e/lifecycle.spec.ts b/web/e2e/lifecycle.spec.ts index a6410f4..986172b 100644 --- a/web/e2e/lifecycle.spec.ts +++ b/web/e2e/lifecycle.spec.ts @@ -4,48 +4,11 @@ // reading their trainee through the scoped view. All fixture data is // invented. -import { execFile, spawn, type ChildProcess } from 'node:child_process'; -import { mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; -import { expect, test } from '@playwright/test'; +import { expect, test } from './fixtures'; -const execFileAsync = promisify(execFile); - -const BINARY = join(import.meta.dirname, '../../target/debug/consolebook-server'); -const BASE = 'http://127.0.0.1:7783'; const PASSWORD = 'invented-passphrase-1'; const TRAINER_PASSWORD = 'trainer-passphrase-3'; -let server: ChildProcess; -let dataDir: string; - -test.beforeAll(async () => { - dataDir = join(mkdtempSync(join(tmpdir(), 'consolebook-e2e-')), 'data'); - server = spawn(BINARY, ['--data-dir', dataDir, 'serve', '--bind', '127.0.0.1:7783'], { - stdio: 'ignore' - }); - for (let i = 0; i < 50; i += 1) { - try { - const response = await fetch(`${BASE}/api/health`); - if (response.ok) return; - } catch { - // not up yet - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } - throw new Error('server did not become healthy'); -}); - -test.afterAll(() => { - server?.kill('SIGTERM'); -}); - -async function setupCode(): Promise { - const { stdout } = await execFileAsync(BINARY, ['--data-dir', dataDir, 'setup-code']); - return stdout.trim(); -} /** Two phases with an advance edge and a remediation loop. */ const content = { @@ -83,11 +46,11 @@ const content = { citations: [] }; -test('assign a trainer, record phase history, and read it scoped', async ({ page }) => { +test('assign a trainer, record phase history, and read it scoped', async ({ page, setupCode }) => { // Initialize and sign in as the administrator. - await page.goto(`${BASE}/`); + await page.goto(`/`); await expect(page).toHaveURL(/\/setup$/); - await page.getByLabel('Setup code').fill(await setupCode()); + await page.getByLabel('Setup code').fill(setupCode); await page.getByLabel('Agency name').fill('Example County Communications'); await page.getByLabel('Administrator username').fill('avery.admin'); await page.getByLabel('Administrator display name').fill('Avery Admin'); @@ -111,20 +74,20 @@ test('assign a trainer, record phase history, and read it scoped', async ({ page // Seed the published program, the trainee, and the enrollment over the // API; the signed-in session's cookies ride page.request. const program = await ( - await page.request.post(`${BASE}/api/programs`, { + await page.request.post(`/api/programs`, { data: { name: content.name } }) ).json(); const version = await ( - await page.request.post(`${BASE}/api/programs/${program.id}/versions`, { data: content }) + await page.request.post(`/api/programs/${program.id}/versions`, { data: content }) ).json(); - await page.request.post(`${BASE}/api/program-versions/${version.id}/publish`, { data: {} }); + await page.request.post(`/api/program-versions/${version.id}/publish`, { data: {} }); const trainee = await ( - await page.request.post(`${BASE}/api/users`, { + await page.request.post(`/api/users`, { data: { username: 'taylor.trainee', display_name: 'Taylor Trainee' } }) ).json(); - await page.request.post(`${BASE}/api/program-versions/${version.id}/enrollments`, { + await page.request.post(`/api/program-versions/${version.id}/enrollments`, { data: { user_id: trainee.id } }); @@ -156,7 +119,7 @@ test('assign a trainer, record phase history, and read it scoped', async ({ page await page.getByRole('link', { name: 'Home' }).click(); await page.getByRole('button', { name: 'Sign out' }).click(); await expect(page).toHaveURL(/\/login$/); - await page.goto(`${BASE}/reset`); + await page.goto(`/reset`); await page.getByLabel('Username').fill('jordan.trainer'); await page.getByLabel('Reset code').fill(trainerCode); await page.getByLabel('New password').fill(TRAINER_PASSWORD); diff --git a/web/e2e/programs.spec.ts b/web/e2e/programs.spec.ts index 8bc94d4..0353a37 100644 --- a/web/e2e/programs.spec.ts +++ b/web/e2e/programs.spec.ts @@ -3,53 +3,16 @@ // frozen, branch a new draft from it, and compare the two versions. // All fixture data is invented. -import { execFile, spawn, type ChildProcess } from 'node:child_process'; -import { mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; -import { expect, test } from '@playwright/test'; +import { expect, test } from './fixtures'; -const execFileAsync = promisify(execFile); - -const BINARY = join(import.meta.dirname, '../../target/debug/consolebook-server'); -const BASE = 'http://127.0.0.1:7782'; const PASSWORD = 'invented-passphrase-1'; -let server: ChildProcess; -let dataDir: string; - -test.beforeAll(async () => { - dataDir = join(mkdtempSync(join(tmpdir(), 'consolebook-e2e-')), 'data'); - server = spawn(BINARY, ['--data-dir', dataDir, 'serve', '--bind', '127.0.0.1:7782'], { - stdio: 'ignore' - }); - for (let i = 0; i < 50; i += 1) { - try { - const response = await fetch(`${BASE}/api/health`); - if (response.ok) return; - } catch { - // not up yet - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } - throw new Error('server did not become healthy'); -}); - -test.afterAll(() => { - server?.kill('SIGTERM'); -}); - -async function setupCode(): Promise { - const { stdout } = await execFileAsync(BINARY, ['--data-dir', dataDir, 'setup-code']); - return stdout.trim(); -} -test('author, publish, and compare a program version', async ({ page }) => { +test('author, publish, and compare a program version', async ({ page, setupCode }) => { // Initialize and sign in. - await page.goto(`${BASE}/`); + await page.goto(`/`); await expect(page).toHaveURL(/\/setup$/); - await page.getByLabel('Setup code').fill(await setupCode()); + await page.getByLabel('Setup code').fill(setupCode); await page.getByLabel('Agency name').fill('Example County Communications'); await page.getByLabel('Administrator username').fill('avery.admin'); await page.getByLabel('Administrator display name').fill('Avery Admin'); diff --git a/web/e2e/server.spec.ts b/web/e2e/server.spec.ts new file mode 100644 index 0000000..a796c37 --- /dev/null +++ b/web/e2e/server.spec.ts @@ -0,0 +1,54 @@ +import { access } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { expect, test } from '@playwright/test'; +import { BINARY, startServer } from './server'; + +test('missing executable and early exit fail at startup', async () => { + await expect(startServer({ binary: `${BINARY}.missing` })).rejects.toThrow(/ENOENT/); + // Node rejects the application's CLI arguments and exits without listening. + await expect(startServer({ binary: process.execPath })).rejects.toThrow(/Consolebook exited/); +}); + +test('an occupied listener cannot be mistaken for the spawned server', async () => { + const listener = createServer((socket) => { + socket.end('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok'); + }); + await new Promise((resolve) => listener.listen(0, '127.0.0.1', resolve)); + try { + const address = listener.address(); + if (!address || typeof address === 'string') throw new Error('expected TCP listener'); + const failure = await startServer({ bind: `127.0.0.1:${address.port}` }).then( + async (server) => { + await server.stop(); + throw new Error('unexpectedly started on an occupied port'); + }, + (error: Error) => error + ); + expect(failure.message).toContain('Consolebook exited'); + expect(failure.message).toContain('binding'); + expect(failure.message).not.toContain('setup_code'); + expect(failure.message).not.toMatch(/[0-9a-f]{32}/); + } finally { + await new Promise((resolve, reject) => { + listener.close((error) => error ? reject(error) : resolve()); + }); + } +}); + +test('parallel installations have distinct listeners and awaited cleanup', async () => { + const first = await startServer(); + try { + const second = await startServer(); + try { + expect(first.url).not.toBe(second.url); + } finally { + await second.stop(); + } + await expect(access(second.root)).rejects.toThrow(); + expect(() => process.kill(second.pid, 0)).toThrow(); + } finally { + await first.stop(); + } + await expect(access(first.root)).rejects.toThrow(); + expect(() => process.kill(first.pid, 0)).toThrow(); +}); diff --git a/web/e2e/server.ts b/web/e2e/server.ts new file mode 100644 index 0000000..9da1159 --- /dev/null +++ b/web/e2e/server.ts @@ -0,0 +1,103 @@ +// Own the child and its scratch directory together. Never return readiness +// from another process that happens to answer on the requested port. +import { execFile, spawn } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { promisify, stripVTControlCharacters } from 'node:util'; + +export const BINARY = join(import.meta.dirname, '../../target/debug/consolebook-server'); +export const execFileAsync = promisify(execFile); + +export async function startServer({ binary = BINARY, bind = '127.0.0.1:0' } = {}) { + const root = await mkdtemp(join(tmpdir(), 'consolebook-e2e-')); + const dataDir = join(root, 'data'); + const child = spawn(binary, ['--data-dir', dataDir, 'serve', '--bind', bind], { + stdio: ['ignore', 'pipe', 'ignore'], + env: { ...process.env, RUST_LOG: 'info', NO_COLOR: '1' } + }); + // Retain bounded startup diagnostics only; setup-code output is discarded. + const diagnostics: string[] = []; + let starting = true; + let spawnError: Error | undefined; + let stopped = false; + const closed = new Promise((resolve) => { + child.once('error', (error) => { + spawnError = error; + }); + child.once('close', (code, signal) => { + stopped = true; + resolve( + spawnError ?? new Error( + `Consolebook exited (code ${code}, signal ${signal})\n${diagnostics.join('\n')}` + ) + ); + }); + }); + const lines = createInterface({ input: child.stdout }); + const listening = new Promise((resolve) => { + lines.on('line', (line) => { + const plain = stripVTControlCharacters(line); + if (starting && /\b(ERROR|WARN)\b/.test(plain) && !plain.includes('setup_code')) { + diagnostics.push(plain.slice(0, 2_000)); + if (diagnostics.length > 10) diagnostics.shift(); + } + // The server emits this only after successfully binding its listener. + // Parsing here discovers an ephemeral test address, never domain data. + const match = plain.match(/\blistening addr=(127\.0\.0\.1:\d+)$/); + if (match) resolve(`http://${match[1]}`); + }); + }); + let cleaned = false; + async function stop() { + if (cleaned) return; + if (!stopped && child.pid) { + child.kill('SIGTERM'); + const force = setTimeout(() => child.kill('SIGKILL'), 5_000); + try { + await closed; + } finally { + clearTimeout(force); + } + } + await closed; + lines.close(); + await rm(root, { recursive: true, force: true }); + cleaned = true; + } + let deadline: ReturnType | undefined; + try { + const url = await Promise.race([ + listening, + closed.then((error) => { + throw error; + }), + new Promise((_, reject) => { + deadline = setTimeout( + () => reject(new Error('Consolebook did not announce a listener within 10s')), + 10_000 + ); + }) + ]); + starting = false; + const response = await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(5_000) }); + if (!response.ok) throw new Error(`Consolebook health returned ${response.status}`); + if (stopped) throw await closed; + return { + url, + root, + pid: child.pid!, + stop, + async setupCode() { + const { stdout } = await execFileAsync(binary, ['--data-dir', dataDir, 'setup-code']); + return stdout.trim(); + } + }; + } catch (error) { + await stop(); + throw error; + } finally { + clearTimeout(deadline); + } +} diff --git a/web/e2e/sessions.spec.ts b/web/e2e/sessions.spec.ts index 5fbf3fe..fe561a9 100644 --- a/web/e2e/sessions.spec.ts +++ b/web/e2e/sessions.spec.ts @@ -3,48 +3,11 @@ // closing with a disposition, and the trainer's own session list. All // fixture data is invented. -import { execFile, spawn, type ChildProcess } from 'node:child_process'; -import { mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; -import { expect, test } from '@playwright/test'; +import { expect, test } from './fixtures'; -const execFileAsync = promisify(execFile); - -const BINARY = join(import.meta.dirname, '../../target/debug/consolebook-server'); -const BASE = 'http://127.0.0.1:7784'; const PASSWORD = 'invented-passphrase-1'; const TRAINER_PASSWORD = 'trainer-passphrase-3'; -let server: ChildProcess; -let dataDir: string; - -test.beforeAll(async () => { - dataDir = join(mkdtempSync(join(tmpdir(), 'consolebook-e2e-')), 'data'); - server = spawn(BINARY, ['--data-dir', dataDir, 'serve', '--bind', '127.0.0.1:7784'], { - stdio: 'ignore' - }); - for (let i = 0; i < 50; i += 1) { - try { - const response = await fetch(`${BASE}/api/health`); - if (response.ok) return; - } catch { - // not up yet - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } - throw new Error('server did not become healthy'); -}); - -test.afterAll(() => { - server?.kill('SIGTERM'); -}); - -async function setupCode(): Promise { - const { stdout } = await execFileAsync(BINARY, ['--data-dir', dataDir, 'setup-code']); - return stdout.trim(); -} const content = { name: 'Example County CTO Program', @@ -80,11 +43,11 @@ const content = { citations: [] }; -test('record, close, and read sessions with local time semantics', async ({ page }) => { +test('record, close, and read sessions with local time semantics', async ({ page, setupCode }) => { // Initialize and sign in as the administrator. - await page.goto(`${BASE}/`); + await page.goto(`/`); await expect(page).toHaveURL(/\/setup$/); - await page.getByLabel('Setup code').fill(await setupCode()); + await page.getByLabel('Setup code').fill(setupCode); await page.getByLabel('Agency name').fill('Example County Communications'); await page.getByLabel('Administrator username').fill('avery.admin'); await page.getByLabel('Administrator display name').fill('Avery Admin'); @@ -99,19 +62,19 @@ test('record, close, and read sessions with local time semantics', async ({ page // Seed the published program, trainee, trainer, enrollment, and // assignment over the API; the session cookie rides page.request. const program = await ( - await page.request.post(`${BASE}/api/programs`, { data: { name: content.name } }) + await page.request.post(`/api/programs`, { data: { name: content.name } }) ).json(); const version = await ( - await page.request.post(`${BASE}/api/programs/${program.id}/versions`, { data: content }) + await page.request.post(`/api/programs/${program.id}/versions`, { data: content }) ).json(); - await page.request.post(`${BASE}/api/program-versions/${version.id}/publish`, { data: {} }); + await page.request.post(`/api/program-versions/${version.id}/publish`, { data: {} }); const trainee = await ( - await page.request.post(`${BASE}/api/users`, { + await page.request.post(`/api/users`, { data: { username: 'taylor.trainee', display_name: 'Taylor Trainee' } }) ).json(); const trainer = await ( - await page.request.post(`${BASE}/api/users`, { + await page.request.post(`/api/users`, { data: { username: 'jordan.trainer', display_name: 'Jordan Trainer', @@ -120,16 +83,16 @@ test('record, close, and read sessions with local time semantics', async ({ page }) ).json(); const enrollment = await ( - await page.request.post(`${BASE}/api/program-versions/${version.id}/enrollments`, { + await page.request.post(`/api/program-versions/${version.id}/enrollments`, { data: { user_id: trainee.id } }) ).json(); - await page.request.post(`${BASE}/api/enrollments/${enrollment.id}/assignments`, { + await page.request.post(`/api/enrollments/${enrollment.id}/assignments`, { data: { trainer_user_id: trainer.id } }); // Record a session for Jordan with agency-local times. - await page.goto(`${BASE}/enrollments/${enrollment.id}`); + await page.goto(`/enrollments/${enrollment.id}`); await expect(page.getByRole('heading', { name: 'Training sessions' })).toBeVisible(); await expect(page.getByText('No sessions recorded.')).toBeVisible(); await page.getByLabel('Business date').fill('2026-06-02'); @@ -159,7 +122,7 @@ test('record, close, and read sessions with local time semantics', async ({ page await expect(sessionsPanel.getByText('completed', { exact: true })).toBeVisible(); // Seed a second, open session so the trainer can work it from home. - await page.request.post(`${BASE}/api/enrollments/${enrollment.id}/sessions`, { + await page.request.post(`/api/enrollments/${enrollment.id}/sessions`, { data: { business_date: '2026-06-03', timezone: 'America/Chicago', @@ -171,7 +134,7 @@ test('record, close, and read sessions with local time semantics', async ({ page // The trainer signs in and finds the sessions on their own list. await page.getByRole('link', { name: 'Home' }).click(); await page.getByRole('button', { name: 'Sign out' }).click(); - await page.goto(`${BASE}/reset`); + await page.goto(`/reset`); await page.getByLabel('Username').fill('jordan.trainer'); await page.getByLabel('Reset code').fill(trainer.reset_code); await page.getByLabel('New password').fill(TRAINER_PASSWORD); diff --git a/web/e2e/shell.spec.ts b/web/e2e/shell.spec.ts index 4e96e22..f21a38b 100644 --- a/web/e2e/shell.spec.ts +++ b/web/e2e/shell.spec.ts @@ -2,57 +2,19 @@ // issue reset code → reset password → sign back in → sign out. // All fixture data is invented. -import { execFile, spawn, type ChildProcess } from 'node:child_process'; -import { mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; -import { expect, test } from '@playwright/test'; +import { expect, test } from './fixtures'; -const execFileAsync = promisify(execFile); - -const BINARY = join(import.meta.dirname, '../../target/debug/consolebook-server'); -const BASE = 'http://127.0.0.1:7781'; const PASSWORD = 'invented-passphrase-1'; const NEW_PASSWORD = 'rotated-passphrase-2'; -let server: ChildProcess; -let dataDir: string; - -test.beforeAll(async () => { - dataDir = join(mkdtempSync(join(tmpdir(), 'consolebook-e2e-')), 'data'); - server = spawn(BINARY, ['--data-dir', dataDir, 'serve', '--bind', '127.0.0.1:7781'], { - stdio: 'ignore' - }); - // Wait for the API to answer. - for (let i = 0; i < 50; i += 1) { - try { - const response = await fetch(`${BASE}/api/health`); - if (response.ok) return; - } catch { - // not up yet - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } - throw new Error('server did not become healthy'); -}); - -test.afterAll(() => { - server?.kill('SIGTERM'); -}); - -async function setupCode(): Promise { - const { stdout } = await execFileAsync(BINARY, ['--data-dir', dataDir, 'setup-code']); - return stdout.trim(); -} -test('full shell lifecycle', async ({ page }) => { +test('full shell lifecycle', async ({ page, setupCode }) => { // An uninitialized installation routes everything to setup. await page.goto('/'); await expect(page).toHaveURL(/\/setup$/); await expect(page.getByRole('heading', { name: 'First-run setup' })).toBeVisible(); - await page.getByLabel('Setup code').fill(await setupCode()); + await page.getByLabel('Setup code').fill(setupCode); await page.getByLabel('Agency name').fill('Example County Communications'); await page.getByLabel('Administrator username').fill('avery.admin'); await page.getByLabel('Administrator display name').fill('Avery Admin'); diff --git a/web/package-lock.json b/web/package-lock.json index dc94d66..f0fb199 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "@sveltejs/adapter-static": "^3.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0", + "@types/node": "^22.0.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "^5.5.0", @@ -1032,6 +1033,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1595,6 +1605,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", diff --git a/web/package.json b/web/package.json index 251ce1e..8aae8aa 100644 --- a/web/package.json +++ b/web/package.json @@ -7,11 +7,12 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && tsc --project tsconfig.e2e.json", "e2e": "playwright test" }, "devDependencies": { "@playwright/test": "^1.49.0", + "@types/node": "^22.0.0", "@sveltejs/adapter-static": "^3.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0", diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 30d5419..ae2510b 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -7,7 +7,6 @@ export default defineConfig({ testDir: './e2e', timeout: 60_000, use: { - baseURL: 'http://127.0.0.1:7781', // Prefer an environment-provided Chromium (CONSOLEBOOK_E2E_CHROMIUM) // over downloading a browser per Playwright version. launchOptions: process.env.CONSOLEBOOK_E2E_CHROMIUM diff --git a/web/tsconfig.e2e.json b/web/tsconfig.e2e.json new file mode 100644 index 0000000..196d2d8 --- /dev/null +++ b/web/tsconfig.e2e.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["e2e/**/*.ts", "playwright.config.ts"] +}