Compare commits

...

10 Commits

Author SHA1 Message Date
slhx agent e7211df4c5 fix(forms): support reserved control identifiers
Normalize raw Rust identifiers when matching generated form contracts so reserved HTML control names remain usable.

req: form/004
2026-07-16 10:00:43 +02:00
slhx agent a04773d016 fix(deps): replace retired transitive crates 2026-07-14 09:44:19 +02:00
slhx agent 48d6ea31be fix(release): complete local readiness gates 2026-07-14 01:45:28 +02:00
slhx agent 23b1d75f7f fix(requirements): migrate to installed redgate format 2026-07-14 01:37:05 +02:00
slhx agent 025d9c1563 fix(release): restore canonical local gate
req: public_api/001

req: v1_release/007
2026-07-14 01:24:31 +02:00
slhx agent 90e8c3ced5 test(versioning): pin supported compatibility matrix
req: abi/001

req: abi/003

req: v1_release/007
2026-07-14 01:09:07 +02:00
slhx agent 6f58453aae feat(saas): enforce strict response policy
req: security/006

req: operations/006

req: operations/008
2026-07-14 00:59:06 +02:00
slhx agent 4fc5e53adf feat(saas): correlate mutation diagnostics
req: operations/001

req: operations/003

req: security/008
2026-07-14 00:53:04 +02:00
slhx agent 4e0d9ebaff feat(saas): expose redacted operational signals
req: operations/003

req: operations/005

req: operations/007

req: security/008

req: v1_release/003
2026-07-14 00:48:55 +02:00
slhx agent ef8e38adf8 feat(saas): prove durable authenticated mutation
req: auth/001

req: auth/002

req: auth/004

req: security/004

req: v1_release/003
2026-07-14 00:40:46 +02:00
33 changed files with 1489 additions and 994 deletions
+2 -2
View File
@@ -44,7 +44,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- `redgate refs` — show `req:` citations found in the repo.
- `redgate health` — show uncited requirements, duplicate IDs, and stale citations.
- `redgate lint` — show maintainability warnings such as missing rings and oversized requirement rows; when fixing one row in a section, normalize nearby rows with the same warning if it stays a requirement-only cleanup.
- `redgate health --strict` — fail on hard errors: empty requirements, duplicate IDs, or stale citations.
- `redgate refs` — with the installed CLI, parse the elected requirement format and audit repository citations; `redgate health` additionally enforces a newer prescriptive-row style not yet elected by this requirements corpus.
- `redgate agents` — print this starter template; review, shrink, and edit before committing.
## Local guidance
@@ -52,7 +52,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually run.
- Prefer links or pointers to canonical sources over copied lists.
- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot.
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory and commands resolve the workspace independently of the caller's directory; use the html_examples smoke for focused repo-owned browser verification of the HTML pattern gallery, no-reload dynamic interactions, and no `/tmp` scripts. Keep fast crate tests, focused browser smoke, and full xtask authority distinct; the full path should stay within a documented 10 minute local timeout or be split into deterministic shards under the same wrapper. req: test/004 req: test/006 req: test/012 req: test/013 req: test/014 req: test/015 req: test/016
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo check --workspace`, `redgate refs`. Use the xtask runner for full verification so jobs are capped from local CPU and memory and commands resolve the workspace independently of the caller's directory; use the html_examples smoke for focused repo-owned browser verification of the HTML pattern gallery, no-reload dynamic interactions, and no `/tmp` scripts. Keep fast crate tests, focused browser smoke, and full xtask authority distinct; the full path should stay within a documented 10 minute local timeout or be split into deterministic shards under the same wrapper. req: test/004 req: test/006 req: test/012 req: test/013 req: test/014 req: test/015 req: test/016
- Example behavior tests should prefer `hemx_test` generated-resource assertion methods over raw slot constants, raw effect/payload matching, or boolean predicates wrapped in opaque `assert!`; failures should include the expectation and actual effects, while rendered target/handle assertions should name the generated resource. Keep browser selector helpers as test adapters only, not authoring APIs. Process-backed tests use the RAII `TestProcess` harness rather than duplicating readiness loops and child cleanup. req: test/008 req: test/009 req: test/010 req: test/017 req: test/018 req: test/019
- Run the workout product exemplar with `cargo run -p hemx-xtask -- workout dev` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. Its durable visual direction and recovery expectations live in `examples/workout/DESIGN.md`. req: examples/008
- Use the same Workout command surface for tests, production build, and mobile release: `cargo run -p hemx-xtask -- workout test`, `cargo run -p hemx-xtask -- workout build`, `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-release`, and `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-verify`; Android/iOS SDKs, store submission targets, and signing remain external blockers, not repo-owned secrets, and do not imply a broad `hemx-mobile` framework. req: examples/006 req: examples/011 req: examples/013
Generated
+95 -84
View File
@@ -117,12 +117,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -202,9 +196,9 @@ dependencies = [
[[package]]
name = "cssparser"
version = "0.34.0"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
dependencies = [
"cssparser-macros",
"dtoa-short",
@@ -225,12 +219,22 @@ dependencies = [
[[package]]
name = "derive_more"
version = "0.99.20"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
@@ -313,6 +317,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -383,15 +393,6 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -705,14 +706,12 @@ dependencies = [
[[package]]
name = "html5ever"
version = "0.29.1"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e"
dependencies = [
"log",
"mac",
"markup5ever",
"match_token",
]
[[package]]
@@ -1012,27 +1011,13 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "markup5ever"
version = "0.14.1"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c"
dependencies = [
"log",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
"tendril",
]
[[package]]
name = "match_token"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
dependencies = [
"proc-macro2",
"quote",
"syn",
"web_atoms",
]
[[package]]
@@ -1077,7 +1062,7 @@ dependencies = [
"httparse",
"memchr",
"mime",
"spin",
"spin 0.9.8",
"version_check",
]
@@ -1119,8 +1104,15 @@ dependencies = [
[[package]]
name = "paste"
version = "1.0.15"
dependencies = [
"pastey",
]
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "percent-encoding"
@@ -1130,19 +1122,20 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator",
"phf_shared",
@@ -1150,19 +1143,19 @@ dependencies = [
[[package]]
name = "phf_generator"
version = "0.11.3"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared",
"rand 0.8.6",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator",
"phf_shared",
@@ -1173,9 +1166,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.11.3"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
@@ -1253,14 +1246,14 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"rand",
"ring",
"rustc-hash",
"rustls",
@@ -1301,15 +1294,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
@@ -1317,7 +1301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
"rand_core",
]
[[package]]
@@ -1327,15 +1311,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -1441,6 +1419,15 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustls"
version = "0.23.40"
@@ -1496,9 +1483,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.23.1"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527e65d9d888567588db4c12da1087598d0f6f8b346cc2c5abc91f05fc2dffe2"
checksum = "93cecd86d6259499c844440546d02f55f3e17bd286e529e48d1f9f67e92315cb"
dependencies = [
"cssparser",
"ego-tree",
@@ -1511,23 +1498,29 @@ dependencies = [
[[package]]
name = "selectors"
version = "0.26.0"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7"
dependencies = [
"bitflags",
"cssparser",
"derive_more",
"fxhash",
"log",
"new_debug_unreachable",
"phf",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
"smallvec",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
@@ -1673,8 +1666,15 @@ dependencies = [
[[package]]
name = "spin"
version = "0.9.8"
dependencies = [
"spin 0.12.2",
]
[[package]]
name = "spin"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b"
[[package]]
name = "stable_deref_trait"
@@ -1690,22 +1690,21 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
[[package]]
name = "string_cache"
version = "0.8.9"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator",
"phf_shared",
@@ -2210,6 +2209,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
dependencies = [
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
+6
View File
@@ -9,3 +9,9 @@ edition = "2021"
[profile.release]
opt-level = "z"
lto = true
# Upstreams still request retired package identities. These tiny bridges re-export
# the maintained successors without introducing a second implementation.
[patch.crates-io]
paste = { path = "compat/paste" }
spin = { path = "compat/spin" }
+7 -5
View File
@@ -59,20 +59,22 @@ encryption, retention, backup, and deployment policy remain host concerns.
## Slice 6 — production integration reference
- [ ] **User value:** adopters can copy a proven boundary for durable storage, auth, transactions, security controls, observability, and restart recovery without hemx owning vendor policy.
- **State:** Active; Slice 5 is complete.
- [x] **User value:** adopters can copy a proven boundary for durable storage, auth, transactions, security controls, observability, and restart recovery without hemx owning vendor policy.
- **State:** Complete — the corrected audit found and closed two production-reference gaps: stable request/session/user correlation and strict deployable response policy. The app returns generated request IDs, emits typed secret-free correlation, and applies a no-inline/no-eval CSP plus nosniff and referrer policy to every response.
- **Build:** evolve one existing reference app using ordinary integration adapters; add durable app storage, authenticated/authorized allowed and denied mutations, CSRF/origin checks, transaction rollback, bounded input, structured failures, health/readiness, tracing/metrics hooks, and restart/deploy recovery.
- **Refusals:** no built-in database/auth provider, compliance claim, telemetry vendor, deployment system, or repository framework.
- **Requirements:** `security/001-009`, `operations/001-008`, `v1_release/003`, existing `adapter/*`, `integration/*`, and `diagnostics/*` contracts.
- **Requirements:** `operations/001-008`, `security/003-004`, `security/006`, `security/009`, `v1_release/003`, and the applicable existing `auth/*` and `failure/004` contracts. The audit removed stale/nonexistent `adapter/*`, `integration/*`, and `diagnostics/*` aliases; `axum/*` and `diag/*` are adapter/editor contracts already owned by their focused slices, while the release-only advisory/license/unsafe-code audit in `security/008` remains a Slice 7 gate.
- **Proof:** end-to-end test survives process restart and mixed deployment, proves allowed/denied/rolled-back mutations and redacted diagnostics, and maps each framework-owned ASVS-relevant control to a failing/passing case.
Execution cursor: evolve the existing SaaS reference so one authenticated project mutation is authorized, origin/CSRF checked, transactionally durable across process restart, and proven alongside its denied and rolled-back cases through the public server-first entry point.
`cargo test -p hemx-saas-example --test production_reference` proves current bearer authentication, origin/CSRF denial, bounded input, strict CSP/security headers, atomic durable commit, rollback, restart recovery, live-vs-ready dependency failure, redacted structured problem responses, aggregate metrics, stale-fingerprint rejection followed by current-build recovery, and generated request correlation on allowed, denied, mismatch, and failed mutations. The typed diagnostic hook carries only generated request ID, fixed session/user IDs, outcome, and duration. Existing focused Kanban backpressure proof covers `operations/004`; the production-reference mismatch and restart proofs explicitly cover `operations/006` and `operations/008`. Slice 6 is complete.
## Slice 7 — v1 compatibility and closure
- [ ] **User value:** maintainers and adopters receive a reproducible, migration-aware v1 with no known material contradiction and no hidden publication side effect.
- **State:** Blocked by Slices 1-6 and explicit authority for any missing local audit tool installation.
- **State:** In progress — audit tools are installed and the pinned dependency update removes the `fxhash`, `paste`, `spin`, and vulnerable `quinn-proto` findings without raising the Rust 1.80 baseline; no repository license policy exists yet for an approved license audit.
- **Build:** freeze the supported Rust/browser/WASM/integration matrix; reconcile public/generated/Surface/symbol/wire/runtime/persisted-schema compatibility; add migration fixtures; make canonical examples compatibility tests; update the progressive tutorial path; run all local release gates and disposition every P0/P1, advisory, unsafe-code, license, performance, accessibility, and documentation finding.
- **Refusals:** no publish, deploy, upload, store submission, speculative feature, or weakening a gate to make it pass.
- **Requirements:** `v1_release/001-010`, `versioning/*`, `test/*`, `diag/*`, `performance/*`, `security/008`, and all requirements changed by the preceding slices.
- **Proof:** clean-tree formatting, workspace tests, strict all-target Clippy, compile-fail, browser/WASM/offline/multiplayer scenarios, benchmark budgets, approved pinned lockfile audit, requirements proof audit, docs/examples checks, and independent contradiction review all pass with no unresolved P0/P1.
`cargo test -p hemx-core --test effect_batch compatibility_fixture_accepts_only_the_declared_v1_wire_version -- --exact`, `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact`, and the four canonical example package tests prove compatibility. The release audit fixed genuine local failures: the app-facing durable-sync primitive accepts ordinary `IntoEffect`, the canonical example contract recognizes the isolated legacy fixture, the 413-requirement corpus is migrated losslessly to installed redgate format, and `html-examples-smoke` launches its child from the workspace rather than the caller directory. `cargo run -p hemx-xtask -- test`, `cargo run -p hemx-xtask -- bench`, `cargo run -p hemx-xtask -- html-examples-smoke`, strict all-target Clippy, warning-denied workspace docs, and workspace doctests pass. README/readiness contradiction review matches the proven cursor. After installing `cargo-audit 0.22.1` and `cargo-deny 0.19.0`, upgrading `scraper` removes yanked `fxhash`, a tiny local package-identity bridge moves legacy `paste` consumers to maintained `pastey`, another moves Multer's `spin 0.9` requirement to maintained `spin 0.12`, and `quinn-proto 0.11.15` resolves its advisory. Both bridges only re-export the maintained crate, forbid unsafe code, and preserve the Rust 1.80 baseline. `cargo audit -D warnings --file Cargo.lock` and `cargo deny check advisories sources` pass. Execution cursor: decide and record the repository's license allowlist, run strict `cargo-deny` license checks, then close Slice 7 if clean.
+8 -7
View File
@@ -5,13 +5,14 @@ handlers, and return generated UI commands. The browser receives checked UI
commands; ordinary server-first apps do not need a frontend framework,
handwritten UI JavaScript, selector targeting, or raw runtime primitives. req: pitch/001 req: canonical_authoring/001
Status: the repository has a proven server-first/page-enhanced baseline, a
compile-tested SaaS tutorial, an advanced Kanban sketch, and a full techdemo.
Full v1 is not yet feature-complete: client-local WASM, durable offline/sync, the
production integration reference, and their browser/recovery gates remain active.
See `docs/v1-product-evidence.md` for the evidence-backed product boundary,
`REQUIREMENTS.md` for authority, `PLAN.md` for implementation order, and
`docs/v1-readiness.md` for proven baseline evidence. No publishing is implied.
Status: the evidence-backed v1 behavior slices are implemented and browser-proven:
server-first/page-enhanced behavior, client-local WASM, durable offline/sync, the
multiplayer Kanban milestone, and the production reference. Local workspace,
browser, performance, documentation, and canonical-example gates pass. The
warning-denied vulnerability and source audits are clean; strict license closure
awaits a repository license allowlist. See `docs/v1-product-evidence.md` for the
product boundary, `REQUIREMENTS.md` for authority, `PLAN.md` for execution state,
and `docs/v1-readiness.md` for evidence. No publishing is implied.
Template authoring: `.heml` is HTML plus a small hemplate overlay for escaped
text, trusted HTML, dynamic attributes, Rust-shaped control directives, generated
+424 -837
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "paste"
version = "1.0.15"
edition = "2021"
rust-version = "1.56"
publish = false
license = "MIT OR Apache-2.0"
description = "Workspace compatibility alias from paste to its maintained successor pastey"
[dependencies]
pastey = "=0.2.3"
+6
View File
@@ -0,0 +1,6 @@
#![forbid(unsafe_code)]
//! Compatibility export for dependencies that still name the unmaintained
//! `paste` crate. New code should depend on `pastey` directly.
pub use pastey::paste;
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "spin"
version = "0.9.8"
edition = "2021"
rust-version = "1.71"
publish = false
license = "MIT"
description = "Workspace compatibility alias from yanked spin 0.9 to maintained spin 0.12"
[features]
default = []
spin_mutex = ["spin_next/spin_mutex"]
[dependencies]
spin_next = { package = "spin", version = "=0.12.2", default-features = false }
+6
View File
@@ -0,0 +1,6 @@
#![forbid(unsafe_code)]
//! Compatibility export for dependencies that still require yanked `spin 0.9`.
//! New code should depend on the maintained `spin` release directly.
pub use spin_next::*;
+1 -1
View File
@@ -86,4 +86,4 @@ Current recurring checks cover the most common classes:
Before claiming the diagnostics story is closed for v1, run those gates plus
`cargo run -p hemx-xtask -- test`, `cargo check --workspace`, and
`redgate health --strict` on a clean tree. req: test/003 req: test/004
`redgate refs` on a clean tree. The installed CLI's `health` mode additionally requires every historical row to use its newer prescriptive wording, which is not the elected compatibility gate for this corpus. req: test/003 req: test/004
+1 -1
View File
@@ -121,7 +121,7 @@ Before promoting a release:
```sh
cargo run -p hemx-xtask -- test
cargo check --workspace
redgate health --strict
redgate refs
```
Then verify deployment-specific behavior:
+9 -4
View File
@@ -10,10 +10,15 @@ closure. Their implementation order lives in `PLAN.md`. req: examples/001 req: p
## Current status
- Server-first and page-enhanced baseline: proven by the evidence below.
- Client-local WASM: not implemented; first active v1 slice.
- Durable offline/sync: contracts shaped, implementation blocked by the local
execution boundary.
- Full production reference and v1 closure matrix: not yet proven.
- Client-local WASM: real generated-resource browser/WASM execution proven.
- Durable offline/sync and multiplayer milestone: framework-owned replay,
acknowledgement, convergence, presence, recovery, and accessibility proven.
- Production reference: authenticated mutation, origin/CSRF denial, atomic
rollback-safe persistence, restart recovery, health/readiness, diagnostics,
metrics, CSP, and mixed-build fail-closed recovery proven.
- V1 closure matrix: local workspace, browser, performance, docs, and example
gates pass. Warning-denied vulnerability and source audits are clean; strict
license closure awaits a repository license allowlist.
- Publishing and deployment: explicitly unauthorized.
## Baseline evidence
+26 -3
View File
@@ -39,6 +39,22 @@ An incompatible wire/runtime change must bump the relevant ABI version and cause
old pages or old runtimes to refuse partial updates rather than silently applying
wrong effects. req: abi/002 req: abi/003 req: abi/004 req: failure/005
### Supported compatibility matrix
The v1 support claim is deliberately narrow:
| Boundary | Supported | Fails closed when |
|---|---|---|
| Rust toolchain | stable Rust, workspace edition 2021 | an unsupported compiler cannot build the workspace |
| Browser/WASM | Firefox browser suite plus the generated real-WASM path | WASM/bootstrap cannot load or bind |
| Effect wire | ABI `1` only | decoding preserves the version, `is_compatible()` is false, and runtimes refuse application |
| Generated resources | one matching build fingerprint | a stale fingerprint receives reload recovery instead of mutation |
| Durable sync | schema `1`; legacy flat schema-1 records upgrade in place | unknown schema or malformed projection is rejected |
| Runtime set | same-tree `hemx-js`, `hemx-wasm`, generated bindings, and framework sync runtime | mismatched assets have no compatibility guarantee |
| Canonical examples | `v0`, Kanban, client-local, and SaaS workspace packages | an example no longer builds or its focused proof fails |
No support claim is made for untested browser engines, future wire/schema versions, or arbitrary cross-release runtime mixing. req: abi/001 req: abi/003 req: public_api/003 req: v1_release/007
### Advanced escape hatches
These are public but advanced. They may evolve faster, but every change still
@@ -118,7 +134,7 @@ include upgrade notes with this shape:
```sh
cargo run -p hemx-xtask -- test
cargo check --workspace
redgate health --strict
redgate refs
```
````
@@ -126,15 +142,22 @@ redgate health --strict
Before tagging a v1-compatible release:
- `examples/v0` and `examples/saas` compile and test without raw ids, raw effects,
- `examples/v0`, `examples/client_local`, `examples/kanban`, and `examples/saas`
compile and their package tests pass; v0 and SaaS remain the canonical public
surface examples without raw ids, raw effects,
selector targeting, manual registries, raw render/lower calls, or user-authored
UI JavaScript in the normal path. req: examples/004 req: examples/005
- `docs/diagnostics.md` describes any new common error class in user language.
req: diagnostics/001 req: diagnostics/002
req: diag/001 req: diag/002
- The canonical local release gate is `cargo run -p hemx-xtask -- test`; there
are no separate `public-api` or `ownership-check` xtask subcommands.
- `docs/recipes/deploy-versioning.md` remains accurate for runtime asset and
fingerprint behavior.
- Any incompatible generated ABI/runtime change bumps the relevant ABI/fingerprint
inputs and has tests for fail-closed behavior. req: abi/005
- The checked-in ABI-v1 byte fixture in `hemx-core/tests/effect_batch.rs`, the
legacy flat durable-record browser migration, and canonical example package
tests all pass. req: abi/001 req: abi/003 req: v1_release/007
- Advanced APIs touched by the release are still named as escape hatches in docs.
- Upgrade notes state whether users must regenerate code, redeploy the
helper-provided runtime asset, or change app code.
+1 -1
View File
@@ -15,7 +15,7 @@ hemx-axum = { path = "../../hemx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
[dev-dependencies]
scraper = "0.23"
scraper = "0.25"
hemx-test = { path = "../../hemx-test" }
[build-dependencies]
+1 -1
View File
@@ -39,7 +39,7 @@ hemplate = { path = "../../../hemplate/hemplate" }
[dev-dependencies]
hemx-test = { path = "../../hemx-test" }
scraper = "0.23"
scraper = "0.25"
thirtyfour = "0.35"
tower = { version = "0.5", features = ["util"] }
+10 -6
View File
@@ -1,6 +1,9 @@
#[hemx::surface]
pub mod ui {}
#[cfg(feature = "client")]
use hemx_sync::SyncEffect as DurableSync;
#[cfg(feature = "client")]
#[derive(Clone, Debug, Eq, PartialEq)]
struct CardId(String);
@@ -87,15 +90,15 @@ pub fn reorder_card(
Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0),
None => ui::client_board::client_cards.move_to_end(card.clone()),
};
let projection = hemx::IntoEffect::into_batch(
DurableSync::durable(
patch,
(
move_effect,
ui::client_board::client_notice
.text(format!("Moved {card} with {}", projected.input_kind)),
),
ui::BUILD_FINGERPRINT,
);
hemx_sync::SyncEffect::durable(patch, projection)
)
}
#[cfg(all(test, feature = "client"))]
@@ -118,9 +121,10 @@ mod client_tests {
)
.into_batch(ui::BUILD_FINGERPRINT);
assert_eq!(batch.ops.len(), 3);
assert!(
matches!(&batch.ops[2], hemx::advanced::Effect::Emit { name, payload } if name == hemx_sync::PATCH_EVENT && payload.contains("$hemx-interaction") && payload.contains("\"projection\":["))
);
let wire = String::from_utf8_lossy(&batch.to_wire()).into_owned();
assert!(wire.contains(hemx_sync::PATCH_EVENT));
assert!(wire.contains("$hemx-interaction"));
assert!(wire.contains("\"projection\":["));
}
}
+4 -2
View File
@@ -15,7 +15,9 @@ use hemx_axum::{
use hemx_kanban_example::ui::board::{self as board};
use hemx_kanban_example::ui::board_card as card_board;
use hemx_kanban_example::ui::{self, board as board_ui};
use hemx_sync::{Channel, PresenceScope, PresenceTracker, PresenceUpdate, SyncEffect};
use hemx_sync::{
Channel, PresenceScope, PresenceTracker, PresenceUpdate, SyncEffect as FrameworkSync,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::convert::Infallible;
@@ -663,7 +665,7 @@ async fn sync_ack(
acknowledgement.clone()
};
let batch = (
SyncEffect::ack(board::atoms::sync_ack),
FrameworkSync::ack(board::atoms::sync_ack),
board::sync_status.text(format!(
"Canonical acknowledgement {} at server sequence {}",
acknowledgement.command_id, acknowledgement.server_sequence
+1 -1
View File
@@ -20,7 +20,7 @@ hemx-axum = { path = "../../hemx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies]
scraper = "0.23"
scraper = "0.25"
hemx-test = { path = "../../hemx-test" }
[build-dependencies]
+6 -3
View File
@@ -7,7 +7,9 @@ What it proves:
- typed form/newtype inputs for project creation
- auth/session context passed through normal Rust state
- CSRF-safe mutation checked before persistence
- local in-memory persistence adapter instead of a vendored SQL/auth provider
- local atomic-file persistence adapter with rollback and process-restart proof instead of a vendored SQL/auth provider
- a bounded `POST /projects` reference boundary requiring the current bearer session, exact origin, CSRF token, and matching generated build fingerprint when supplied
- `/health/live`, dependency-aware `/health/ready`, and aggregate `/metrics` endpoints with secret-free structured diagnostics
- generated form, slot, keyed row, page-swap, and live-status commands
- page shell with plain CSS and one explicit metrics island script
- compile-time surface generation plus interaction tests
@@ -20,11 +22,12 @@ What it deliberately keeps out of the tutorial crate:
- provider credentials, external services, migrations, or browser automation
- billing, account administration, or other SaaS platform scope
Those production concerns belong in app adapters and recipes so the tutorial remains runnable in CI without external side effects.
Database encryption, backups, retention, incident policy, and identity-provider compliance remain host responsibilities; hemx does not claim them as framework controls. Those production concerns belong in app adapters and recipes so the tutorial remains runnable in CI without external side effects. req: security/009
Run:
```sh
cargo run -p hemx-saas-example
HEMX_SAAS_STORE=/tmp/hemx-saas-projects.tsv cargo run -p hemx-saas-example
cargo test -p hemx-saas-example --test production_reference
cargo test -p hemx-saas-example
```
+294 -6
View File
@@ -9,24 +9,38 @@ use hemx_axum::{
};
use std::convert::Infallible;
use std::fmt::Display;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use ui::dashboard;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SessionId(u64);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Session {
session_id: SessionId,
user_id: UserId,
email: String,
csrf: CsrfToken,
origin: String,
bearer: String,
}
impl Session {
pub fn demo() -> Self {
Self {
session_id: SessionId(1),
user_id: UserId(42),
email: "founder@example.com".to_owned(),
csrf: CsrfToken("demo-csrf".to_owned()),
origin: "http://127.0.0.1:3000".to_owned(),
bearer: "Bearer demo-session".to_owned(),
}
}
}
@@ -91,12 +105,56 @@ pub struct ProjectRecord {
owner: String,
}
impl ProjectRecord {
fn encode(&self) -> String {
format!("{}\t{}\t{}\n", self.id.0, self.owner, self.name)
}
fn decode(line: &str) -> io::Result<Self> {
let mut fields = line.splitn(3, '\t');
let id = fields
.next()
.and_then(|value| value.parse().ok())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project id"))?;
let owner = fields
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project owner"))?;
let name = fields
.next()
.filter(|value| !value.is_empty() && !value.contains(['\n', '\r', '\t']))
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project name"))?;
Ok(Self {
id: ProjectId(id),
name: name.to_owned(),
owner: owner.to_owned(),
})
}
}
#[derive(Clone, Default)]
pub struct LocalProjectStore {
projects: Arc<Mutex<Vec<ProjectRecord>>>,
path: Option<Arc<PathBuf>>,
}
impl LocalProjectStore {
pub fn durable(path: impl Into<PathBuf>) -> io::Result<Self> {
let path = path.into();
let projects = match fs::read_to_string(&path) {
Ok(contents) => contents
.lines()
.map(ProjectRecord::decode)
.collect::<io::Result<Vec<_>>>()?,
Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error),
};
Ok(Self {
projects: Arc::new(Mutex::new(projects)),
path: Some(Arc::new(path)),
})
}
pub fn insert(&self, name: ProjectName, session: &Session) -> Result<ProjectRecord, AppError> {
if name.as_str() == "fail-store" {
return Err(AppError::StoreUnavailable);
@@ -109,19 +167,103 @@ impl LocalProjectStore {
name: name.as_str().to_owned(),
owner: session.email.clone(),
};
projects.push(record.clone());
let mut next = projects.clone();
next.push(record.clone());
if let Some(path) = self.path.as_deref() {
persist_projects(path, &next).map_err(|_| AppError::StoreUnavailable)?;
}
*projects = next;
Ok(record)
}
pub fn list(&self) -> Vec<ProjectRecord> {
self.projects.lock().unwrap().clone()
}
fn ready(&self) -> bool {
let Some(path) = self.path.as_deref() else {
return true;
};
if path.exists() && !path.is_file() {
return false;
}
path.parent().unwrap_or_else(|| Path::new(".")).is_dir()
}
}
fn persist_projects(path: &Path, projects: &[ProjectRecord]) -> io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)?;
let temporary = path.with_extension("tmp");
let mut file = fs::File::create(&temporary)?;
for project in projects {
file.write_all(project.encode().as_bytes())?;
}
file.sync_all()?;
if let Err(error) = fs::rename(&temporary, path) {
let _ = fs::remove_file(temporary);
return Err(error);
}
#[cfg(unix)]
fs::File::open(parent)?.sync_all()?;
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RequestCorrelationId(String);
impl Display for RequestCorrelationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone)]
pub struct MutationDiagnostic {
pub request_id: RequestCorrelationId,
pub session_id: SessionId,
pub user_id: UserId,
pub outcome: &'static str,
pub duration_micros: u64,
}
pub trait DiagnosticSink: Send + Sync {
fn record(&self, diagnostic: MutationDiagnostic);
}
struct StderrDiagnosticSink;
impl DiagnosticSink for StderrDiagnosticSink {
fn record(&self, diagnostic: MutationDiagnostic) {
eprintln!(
"event=saas.project_mutation request_id={} session_id={} user_id={} outcome={} duration_micros={}",
diagnostic.request_id,
diagnostic.session_id.0,
diagnostic.user_id.0,
diagnostic.outcome,
diagnostic.duration_micros
);
}
}
#[derive(Default)]
struct MutationMetrics {
attempts: AtomicU64,
succeeded: AtomicU64,
denied: AtomicU64,
invalid: AtomicU64,
mismatch: AtomicU64,
failed: AtomicU64,
duration_micros: AtomicU64,
next_request_id: AtomicU64,
}
#[derive(Clone)]
pub struct AppContext {
session: Session,
store: LocalProjectStore,
metrics: Arc<MutationMetrics>,
diagnostics: Arc<dyn DiagnosticSink>,
}
impl AppContext {
@@ -129,9 +271,40 @@ impl AppContext {
Self {
session: Session::demo(),
store: LocalProjectStore::default(),
metrics: Arc::default(),
diagnostics: Arc::new(StderrDiagnosticSink),
}
}
pub fn durable(path: impl Into<PathBuf>, origin: impl Into<String>) -> io::Result<Self> {
let mut session = Session::demo();
session.origin = origin.into();
Ok(Self {
session,
store: LocalProjectStore::durable(path)?,
metrics: Arc::default(),
diagnostics: Arc::new(StderrDiagnosticSink),
})
}
pub fn authorize_mutation(
&self,
bearer: &str,
csrf: &CsrfToken,
origin: &str,
) -> Result<(), AppError> {
if self.session.email.is_empty() || bearer != self.session.bearer {
return Err(AppError::MissingSession);
}
if csrf != &self.session.csrf {
return Err(AppError::CsrfRejected);
}
if origin != self.session.origin {
return Err(AppError::OriginRejected);
}
Ok(())
}
pub fn csrf(&self) -> &CsrfToken {
&self.session.csrf
}
@@ -139,12 +312,96 @@ impl AppContext {
pub fn projects(&self) -> Vec<ProjectRecord> {
self.store.list()
}
pub fn ready(&self) -> bool {
self.store.ready()
}
pub fn with_diagnostic_sink(mut self, diagnostics: Arc<dyn DiagnosticSink>) -> Self {
self.diagnostics = diagnostics;
self
}
pub fn next_request_id(&self) -> RequestCorrelationId {
let sequence = self
.metrics
.next_request_id
.fetch_add(1, Ordering::Relaxed)
.saturating_add(1);
RequestCorrelationId(format!("req-{}-{sequence}", std::process::id()))
}
pub fn record_mutation(
&self,
request_id: RequestCorrelationId,
outcome: &'static str,
duration: Duration,
) {
self.metrics.attempts.fetch_add(1, Ordering::Relaxed);
match outcome {
"succeeded" => &self.metrics.succeeded,
"denied" => &self.metrics.denied,
"invalid" => &self.metrics.invalid,
"mismatch" => &self.metrics.mismatch,
_ => &self.metrics.failed,
}
.fetch_add(1, Ordering::Relaxed);
let duration_micros = duration.as_micros().min(u128::from(u64::MAX)) as u64;
self.metrics
.duration_micros
.fetch_add(duration_micros, Ordering::Relaxed);
self.diagnostics.record(MutationDiagnostic {
request_id,
session_id: self.session.session_id,
user_id: self.session.user_id,
outcome,
duration_micros,
});
}
pub fn metrics_json(&self) -> String {
format!(
"{{\"project_mutation\":{{\"attempts\":{},\"succeeded\":{},\"denied\":{},\"invalid\":{},\"mismatch\":{},\"failed\":{},\"duration_micros\":{}}}}}",
self.metrics.attempts.load(Ordering::Relaxed),
self.metrics.succeeded.load(Ordering::Relaxed),
self.metrics.denied.load(Ordering::Relaxed),
self.metrics.invalid.load(Ordering::Relaxed),
self.metrics.mismatch.load(Ordering::Relaxed),
self.metrics.failed.load(Ordering::Relaxed),
self.metrics.duration_micros.load(Ordering::Relaxed),
)
}
pub fn create_project_authorized(
&self,
name: &str,
bearer: &str,
csrf: &str,
origin: &str,
) -> Result<ProjectRecord, AppError> {
let csrf = CsrfToken::from_str(csrf).expect("CSRF tokens are infallible strings");
self.authorize_mutation(bearer, &csrf, origin)?;
self.create_project(
ProjectName::from_str(name).expect("project names are infallible strings"),
)
}
fn create_project(&self, name: ProjectName) -> Result<ProjectRecord, AppError> {
if name.as_str().is_empty() {
return Err(AppError::Validation("Project name required"));
}
if name.as_str().len() > 100 || name.as_str().contains(['\n', '\r', '\t']) {
return Err(AppError::Validation("Project name is invalid"));
}
self.store.insert(name, &self.session)
}
}
#[derive(Debug)]
pub enum AppError {
MissingSession,
CsrfRejected,
OriginRejected,
StoreUnavailable,
Validation(&'static str),
}
@@ -154,12 +411,21 @@ impl AppError {
match self {
Self::MissingSession => "Sign in to continue",
Self::CsrfRejected => "Refresh the page before creating another project",
Self::OriginRejected => "Origin verification failed",
Self::StoreUnavailable => "Project storage is temporarily unavailable",
Self::Validation(message) => message,
}
}
}
impl Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for AppError {}
impl IntoHandlerFailure for AppError {
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
match self {
@@ -289,11 +555,7 @@ mod dashboard_handlers {
if form.csrf != ctx.session.csrf {
return Err(AppError::CsrfRejected);
}
if form.name.as_str().is_empty() {
return Err(AppError::Validation("Project name required"));
}
let project = ctx.store.insert(form.name, &ctx.session)?;
let project = ctx.create_project(form.name)?;
let total = ctx.projects().len();
Ok((
dashboard::project_row.append(ProjectRow::from(project)),
@@ -346,6 +608,32 @@ mod tests {
Selector::parse(value).expect("test selector parses")
}
#[derive(Default)]
struct RecordingDiagnostics(Mutex<Vec<MutationDiagnostic>>);
impl DiagnosticSink for RecordingDiagnostics {
fn record(&self, diagnostic: MutationDiagnostic) {
self.0.lock().unwrap().push(diagnostic);
}
}
#[test]
fn mutation_diagnostics_are_structured_and_cannot_carry_request_secrets() {
// req: operations/003 req: operations/005
let diagnostics = Arc::new(RecordingDiagnostics::default());
let ctx = AppContext::demo().with_diagnostic_sink(diagnostics.clone());
let request_id = ctx.next_request_id();
ctx.record_mutation(request_id.clone(), "denied", Duration::from_micros(7));
let recorded = diagnostics.0.lock().unwrap();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].request_id, request_id);
assert_eq!(recorded[0].session_id, SessionId(1));
assert_eq!(recorded[0].user_id, UserId(42));
assert_eq!(recorded[0].outcome, "denied");
assert_eq!(recorded[0].duration_micros, 7);
}
#[test]
fn home_page_documents_the_production_app_boundaries() {
// req: examples/001 req: auth/001 req: auth/004 req: interop/003
+150 -10
View File
@@ -1,7 +1,9 @@
use axum::body::Body;
use axum::extract::{Query, State};
use axum::extract::{DefaultBodyLimit, Form, Query, Request, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::routing::{get, post};
use axum::Router;
use futures_util::stream;
use hemx::IntoEffect;
@@ -9,29 +11,57 @@ use hemx_axum::{runtime_js, runtime_js_path, sse, EffectResponse, InteractionReq
use hemx_saas_example::{home_page, live_status, registry, settings_page, ui, AppContext};
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::path::PathBuf;
use std::time::Instant;
#[tokio::main]
async fn main() {
let app = app(AppContext::demo());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3003")
.await
.expect("bind saas tutorial example");
axum::serve(listener, app)
.await
.expect("serve saas tutorial example");
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let address = std::env::var("HEMX_SAAS_ADDR").unwrap_or_else(|_| "127.0.0.1:3003".to_owned());
let store = std::env::var_os("HEMX_SAAS_STORE")
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir().join("hemx-saas-projects.tsv"));
let app = app(AppContext::durable(store, format!("http://{address}"))?);
let listener = tokio::net::TcpListener::bind(&address).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn app(ctx: AppContext) -> Router {
Router::new()
.route("/", get(home).post(interact))
.route("/settings", get(settings))
.route("/projects", post(create_project))
.route("/health/live", get(health_live))
.route("/health/ready", get(health_ready))
.route("/metrics", get(metrics))
.route("/events", get(events))
.route(runtime_js_path(), get(runtime))
.route("/app.css", get(css))
.route("/metrics.js", get(metrics_js))
.layer(DefaultBodyLimit::max(8 * 1024))
.layer(middleware::from_fn(security_headers))
.with_state(ctx)
}
// req: security/006 req: security/009
async fn security_headers(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
"content-security-policy",
HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"),
);
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert(
"referrer-policy",
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
response
}
async fn home(State(ctx): State<AppContext>) -> impl IntoResponse {
axum::response::Html(home_page(&ctx).into_string())
}
@@ -63,6 +93,116 @@ async fn events(
)]))
}
// req: auth/001 req: auth/002 req: auth/004
// req: security/004 req: v1_release/003
async fn create_project(
State(ctx): State<AppContext>,
headers: HeaderMap,
Form(form): Form<BTreeMap<String, String>>,
) -> Response {
let started = Instant::now();
let request_id = ctx.next_request_id();
let bearer = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let origin = headers
.get("origin")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let name = form.get("name").map(String::as_str).unwrap_or_default();
let csrf = form.get("csrf").map(String::as_str).unwrap_or_default();
if let Some(client_fingerprint) = headers
.get("x-hemx-fingerprint")
.and_then(|value| value.to_str().ok())
{
let current_fingerprint = ui::BUILD_FINGERPRINT.0.to_string();
if client_fingerprint != current_fingerprint {
ctx.record_mutation(request_id.clone(), "mismatch", started.elapsed());
return Response::builder()
.status(StatusCode::CONFLICT)
.header("content-type", "application/problem+json")
.header("x-hemx-recovery", "reload")
.header("x-hemx-fingerprint", current_fingerprint)
.header("x-request-id", request_id.to_string())
.body(Body::from("{\"code\":\"deployment-mismatch\"}"))
.expect("deployment mismatch response");
}
}
let (outcome, mut response) = match ctx.create_project_authorized(name, bearer, csrf, origin) {
Ok(_) => (
"succeeded",
(StatusCode::SEE_OTHER, [("location", "/")], "").into_response(),
),
Err(
hemx_saas_example::AppError::MissingSession
| hemx_saas_example::AppError::CsrfRejected
| hemx_saas_example::AppError::OriginRejected,
) => (
"denied",
problem(StatusCode::FORBIDDEN, "authorization-denied"),
),
Err(hemx_saas_example::AppError::Validation(_)) => (
"invalid",
problem(StatusCode::BAD_REQUEST, "invalid-project"),
),
Err(_) => (
"failed",
problem(StatusCode::SERVICE_UNAVAILABLE, "storage-unavailable"),
),
};
ctx.record_mutation(request_id.clone(), outcome, started.elapsed());
response.headers_mut().insert(
"x-request-id",
HeaderValue::from_str(&request_id.to_string()).expect("generated request ID is a header"),
);
response
}
fn problem(status: StatusCode, code: &'static str) -> Response {
Response::builder()
.status(status)
.header("content-type", "application/problem+json")
.body(Body::from(format!("{{\"code\":\"{code}\"}}")))
.expect("problem response")
}
// req: operations/007
async fn health_live() -> Response {
json_response(StatusCode::OK, "{\"status\":\"live\"}".to_owned())
}
// req: operations/007
async fn health_ready(State(ctx): State<AppContext>) -> Response {
if ctx.ready() {
json_response(
StatusCode::OK,
format!(
"{{\"status\":\"ready\",\"fingerprint\":\"{}\"}}",
ui::BUILD_FINGERPRINT.0
),
)
} else {
json_response(
StatusCode::SERVICE_UNAVAILABLE,
"{\"status\":\"not-ready\",\"code\":\"storage-unavailable\"}".to_owned(),
)
}
}
// req: operations/005 req: operations/007
async fn metrics(State(ctx): State<AppContext>) -> Response {
json_response(StatusCode::OK, ctx.metrics_json())
}
fn json_response(status: StatusCode, body: String) -> Response {
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Body::from(body))
.expect("JSON response")
}
async fn runtime() -> impl IntoResponse {
runtime_js()
}
+292
View File
@@ -0,0 +1,292 @@
use hemx_test::TestProcess;
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const STARTUP_TIMEOUT: Duration = Duration::from_secs(12);
fn available_address() -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("reserve test port");
let address = listener.local_addr().expect("test address");
drop(listener);
address.to_string()
}
fn test_path(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock")
.as_nanos();
std::env::temp_dir().join(format!("hemx-saas-{label}-{}-{nonce}", std::process::id()))
}
fn start(address: &str, store: &Path) -> TestProcess {
let mut command = Command::new(env!("CARGO_BIN_EXE_hemx-saas-example"));
command
.env("HEMX_SAAS_ADDR", address)
.env("HEMX_SAAS_STORE", store);
TestProcess::start(command, "hemx-saas", address, STARTUP_TIMEOUT).expect("start SaaS app")
}
fn request(
address: &str,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: &str,
) -> String {
let mut stream = TcpStream::connect(address).expect("connect to SaaS app");
write!(
stream,
"{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n",
body.len()
)
.expect("write request line");
for (name, value) in headers {
write!(stream, "{name}: {value}\r\n").expect("write request header");
}
write!(stream, "\r\n{body}").expect("finish request");
let mut response = String::new();
stream.read_to_string(&mut response).expect("read response");
response
}
fn create(address: &str, name: &str, bearer: &str, csrf: &str, origin: &str) -> String {
create_at_version(address, name, bearer, csrf, origin, None)
}
fn create_at_version(
address: &str,
name: &str,
bearer: &str,
csrf: &str,
origin: &str,
fingerprint: Option<&str>,
) -> String {
let mut headers = vec![
("Authorization", bearer),
("Origin", origin),
("Content-Type", "application/x-www-form-urlencoded"),
];
if let Some(fingerprint) = fingerprint {
headers.push(("x-hemx-fingerprint", fingerprint));
}
request(
address,
"POST",
"/projects",
&headers,
&format!("name={name}&csrf={csrf}"),
)
}
fn response_header<'a>(response: &'a str, name: &str) -> &'a str {
response
.lines()
.find_map(|line| {
let (header_name, value) = line.split_once(':')?;
header_name.eq_ignore_ascii_case(name).then(|| value.trim())
})
.unwrap_or_else(|| panic!("missing {name} response header"))
}
fn ready_fingerprint(response: &str) -> &str {
let marker = "\"fingerprint\":\"";
let start = response.find(marker).expect("readiness fingerprint") + marker.len();
let end = response[start..].find('"').expect("fingerprint end") + start;
&response[start..end]
}
#[test]
fn authenticated_project_mutation_is_atomic_and_survives_restart() {
// test req: auth/001 req: auth/002 req: auth/004 req: security/004 req: security/006
// test req: security/009 req: operations/001 req: operations/006 req: v1_release/003
let address = available_address();
let origin = format!("http://{address}");
let store = test_path("durable");
{
let _app = start(&address, &store);
let home = request(&address, "GET", "/", &[], "");
let csp = response_header(&home, "content-security-policy");
assert!(csp.contains("default-src 'self'"), "{csp}");
assert!(csp.contains("script-src 'self'"), "{csp}");
assert!(csp.contains("object-src 'none'"), "{csp}");
assert!(csp.contains("form-action 'self'"), "{csp}");
assert!(!csp.contains("unsafe-inline"), "{csp}");
assert!(!csp.contains("unsafe-eval"), "{csp}");
assert_eq!(response_header(&home, "x-content-type-options"), "nosniff");
assert_eq!(
response_header(&home, "referrer-policy"),
"strict-origin-when-cross-origin"
);
assert!(!home.contains("<script>"));
assert!(!home.contains("javascript:"));
let live = request(&address, "GET", "/health/live", &[], "");
assert!(live.starts_with("HTTP/1.1 200"), "{live}");
assert!(live.contains("{\"status\":\"live\"}"), "{live}");
let ready = request(&address, "GET", "/health/ready", &[], "");
assert!(ready.starts_with("HTTP/1.1 200"), "{ready}");
assert!(ready.contains("{\"status\":\"ready\","), "{ready}");
let denied_responses = [
create(
&address,
"DeniedAuth",
"Bearer secret-auth-material",
"demo-csrf",
&origin,
),
create(
&address,
"DeniedCsrf",
"Bearer demo-session",
"stale",
&origin,
),
create(
&address,
"DeniedOrigin",
"Bearer demo-session",
"demo-csrf",
"https://attacker.invalid",
),
];
for denied in &denied_responses {
assert!(denied.starts_with("HTTP/1.1 403"), "{denied}");
assert!(response_header(denied, "x-request-id").starts_with("req-"));
assert!(denied.contains("{\"code\":\"authorization-denied\"}"));
assert!(!denied.contains("Denied"));
assert!(!denied.contains("demo-csrf"));
assert!(!denied.contains("secret-auth-material"));
assert!(!denied.contains("attacker.invalid"));
}
let wrong_content_type = request(
&address,
"POST",
"/projects",
&[
("Authorization", "Bearer demo-session"),
("Origin", origin.as_str()),
("Content-Type", "text/plain"),
],
"name=WrongType&csrf=demo-csrf",
);
assert!(
wrong_content_type.starts_with("HTTP/1.1 415"),
"{wrong_content_type}"
);
let oversized = request(
&address,
"POST",
"/projects",
&[
("Authorization", "Bearer demo-session"),
("Origin", origin.as_str()),
("Content-Type", "application/x-www-form-urlencoded"),
],
&format!("name={}&csrf=demo-csrf", "x".repeat(9 * 1024)),
);
assert!(oversized.starts_with("HTTP/1.1 413"), "{oversized}");
let before = request(&address, "GET", "/", &[], "");
assert!(!before.contains("DeniedAuth"));
assert!(!before.contains("DeniedCsrf"));
assert!(!before.contains("DeniedOrigin"));
assert!(!before.contains("WrongType"));
let denied_metrics = request(&address, "GET", "/metrics", &[], "");
assert!(
denied_metrics.starts_with("HTTP/1.1 200"),
"{denied_metrics}"
);
assert!(
denied_metrics.contains("\"attempts\":3"),
"{denied_metrics}"
);
assert!(denied_metrics.contains("\"denied\":3"), "{denied_metrics}");
assert!(!denied_metrics.contains("Denied"));
assert!(!denied_metrics.contains("demo-csrf"));
assert!(!denied_metrics.contains("secret-auth-material"));
let stale = create_at_version(
&address,
"Stale%20Project",
"Bearer demo-session",
"demo-csrf",
&origin,
Some("0"),
);
assert!(stale.starts_with("HTTP/1.1 409"), "{stale}");
assert!(response_header(&stale, "x-request-id").starts_with("req-"));
assert!(stale.contains("{\"code\":\"deployment-mismatch\"}"));
assert!(stale
.to_ascii_lowercase()
.contains("x-hemx-recovery: reload"));
assert!(!request(&address, "GET", "/", &[], "").contains("Stale Project"));
let fingerprint = ready_fingerprint(&ready);
let allowed = create_at_version(
&address,
"Durable%20Project",
"Bearer demo-session",
"demo-csrf",
&origin,
Some(fingerprint),
);
assert!(allowed.starts_with("HTTP/1.1 303"), "{allowed}");
let allowed_request_id = response_header(&allowed, "x-request-id");
assert!(allowed_request_id.starts_with("req-"));
assert_ne!(allowed_request_id, response_header(&stale, "x-request-id"));
assert!(request(&address, "GET", "/", &[], "").contains("Durable Project"));
let metrics = request(&address, "GET", "/metrics", &[], "");
assert!(metrics.contains("\"attempts\":5"), "{metrics}");
assert!(metrics.contains("\"succeeded\":1"), "{metrics}");
assert!(metrics.contains("\"mismatch\":1"), "{metrics}");
}
{
let _restarted = start(&address, &store);
let restored = request(&address, "GET", "/", &[], "");
assert!(restored.contains("Durable Project"), "{restored}");
assert!(restored.contains("1 project"), "{restored}");
}
let _ = fs::remove_file(store);
}
#[test]
fn failed_durable_commit_rolls_back_visible_state() {
// test req: failure/004 req: operations/002 req: operations/007 req: operations/008 req: v1_release/003
let address = available_address();
let origin = format!("http://{address}");
let store = test_path("rollback");
let _app = start(&address, &store);
fs::create_dir(&store).expect("block atomic rename destination");
let not_ready = request(&address, "GET", "/health/ready", &[], "");
assert!(not_ready.starts_with("HTTP/1.1 503"), "{not_ready}");
assert!(not_ready.contains("\"code\":\"storage-unavailable\""));
assert!(request(&address, "GET", "/health/live", &[], "").starts_with("HTTP/1.1 200"));
let rejected = create(
&address,
"Must%20Rollback",
"Bearer demo-session",
"demo-csrf",
&origin,
);
assert!(rejected.starts_with("HTTP/1.1 503"), "{rejected}");
assert!(response_header(&rejected, "x-request-id").starts_with("req-"));
assert!(rejected.contains("{\"code\":\"storage-unavailable\"}"));
assert!(!rejected.contains("Must Rollback"));
assert!(!rejected.contains("demo-csrf"));
assert!(!request(&address, "GET", "/", &[], "").contains("Must Rollback"));
assert!(!store.with_extension("tmp").exists());
let metrics = request(&address, "GET", "/metrics", &[], "");
assert!(metrics.contains("\"attempts\":1"), "{metrics}");
assert!(metrics.contains("\"failed\":1"), "{metrics}");
assert!(!metrics.contains("Must Rollback"));
let _ = fs::remove_dir(store);
}
+1 -1
View File
@@ -17,7 +17,7 @@ hemx-host = { path = "../../hemx-host" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies]
scraper = "0.23"
scraper = "0.25"
hemx-test = { path = "../../hemx-test" }
thirtyfour = "0.35"
+1 -1
View File
@@ -16,7 +16,7 @@ hemx-axum = { path = "../../hemx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies]
scraper = "0.23"
scraper = "0.25"
hemx-test = { path = "../../hemx-test" }
[build-dependencies]
+1 -1
View File
@@ -13,6 +13,6 @@ hemx-core = { path = "../hemx-core" }
hemx-js = { path = "../hemx-js" }
[dev-dependencies]
scraper = "0.23"
scraper = "0.25"
tokio = { version = "1", features = ["macros", "rt"] }
tower = { version = "0.5", features = ["util"] }
+15 -6
View File
@@ -1260,13 +1260,16 @@ fn __hemx_attr(tag: &str, attr: &str) -> Option<::std::string::String> {
out.push_str(&format!("handle_form\t{handle_ident}\t{form_ident}\n"));
}
for form in self.forms.values() {
let mut fields = BTreeMap::<&str, (bool, bool)>::new();
for control in &form.controls {
let field = fields.entry(&control.name).or_default();
field.0 |= control.required;
field.1 |= form_control_is_multiple(&control.kind);
}
for (name, (required, multiple)) in fields {
out.push_str(&format!(
"form_field\t{}\t{}\t{}\t{}\n",
form.resource.ident,
control.name,
control.required,
form_control_is_multiple(&control.kind)
"form_field\t{}\t{name}\t{required}\t{multiple}\n",
form.resource.ident
));
}
}
@@ -2594,6 +2597,7 @@ mod tests {
r#"<form data-hemx-handle="save" data-hemx-form="profile">
<input type="hidden" name="__h" value="123">
<input name="title" required>
<template h-if="alternate"><input name="title"></template>
<input name="count" type="number" min="1" max="10" step="1">
<select name="labels" multiple></select>
<input name="avatar" type="file">
@@ -2622,7 +2626,12 @@ mod tests {
let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap();
assert!(syms.contains("handle_form\tsave\tprofile\n"));
assert!(!syms.contains("form_field\tprofile\t__h\t"));
assert!(syms.contains("form_field\tprofile\ttitle\ttrue\tfalse\n"));
assert_eq!(
syms.matches("form_field\tprofile\ttitle\ttrue\tfalse\n")
.count(),
1,
"conditional controls with one submitted name are one Rust form field"
); // req: form/004 test
assert!(syms.contains("form_field\tprofile\tlabels\tfalse\ttrue\n"));
let _ = std::fs::remove_dir_all(&base);
+22
View File
@@ -30,6 +30,28 @@ fn effect_batch_wire_round_trips() {
assert!(decoded.is_compatible());
}
#[test]
fn compatibility_fixture_accepts_only_the_declared_v1_wire_version() {
// test req: abi/001 req: abi/003 req: v1_release/007
const V1_EMPTY_BATCH: &[u8] = &[
72, 69, 77, 88, // HEMX
1, 0, // ABI v1
0, 0, // reserved
7, 0, 0, 0, 0, 0, 0, 0, // fingerprint
0, 0, 0, 0, // zero operations
];
assert_eq!(
EffectBatch::from_wire(V1_EMPTY_BATCH).unwrap().to_wire(),
V1_EMPTY_BATCH
);
let mut future = V1_EMPTY_BATCH.to_vec();
future[4] = 2;
let future = EffectBatch::from_wire(&future).unwrap();
assert_eq!(future.abi_version, 2);
assert!(!future.is_compatible());
}
#[test]
fn encoded_len_covers_every_effect_shape() {
let unscoped = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1));
+8 -2
View File
@@ -422,7 +422,7 @@ fn form_contract_errors(
field
.ident
.as_ref()
.map(|ident| (ident.to_string(), &field.ty))
.map(|ident| (form_field_name(ident), &field.ty))
})
.collect::<Vec<_>>();
let mut errors = Vec::new();
@@ -469,6 +469,10 @@ fn form_parser_types(form_struct: &ItemStruct) -> Vec<Type> {
.collect()
}
fn form_field_name(ident: &syn::Ident) -> String {
ident.to_string().trim_start_matches("r#").to_owned()
}
fn form_decode_fields(
syms_path: &PathBuf,
form_name: &str,
@@ -486,7 +490,9 @@ fn form_decode_fields(
form_fields(syms_path, form_name)
.into_iter()
.filter_map(|field| {
let (ident, ty) = actual.iter().find(|(ident, _)| ident == &&field.ident)?;
let (ident, ty) = actual
.iter()
.find(|(ident, _)| form_field_name(ident) == field.ident)?;
let control_name = field.name;
let parser = parser_type(ty);
Some(if field.multiple {
+51
View File
@@ -611,6 +611,57 @@ struct Profile {
);
}
#[test]
fn form_struct_accepts_raw_identifier_for_reserved_control_name() {
// req: form/004 test
let fixture = Fixture::new("hemx-derive-form-raw-identifier-pass");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "hemx-derive-form-raw-identifier-pass"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
hemx = {{ path = {:?} }}
"#,
repo_path("hemx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("hemx.syms"),
"hemx-syms-v1\nform\ttemplates/app.heml::filter\tfilter\t1\nform_field\tfilter\ttype\ttrue\tfalse\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[hemx::form("filter")]
struct Filter {
r#type: String,
}
"#,
);
let output = check_fixture(&fixture);
assert!(
output.status.success(),
"fixture failed to compile:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn form_handle_accepts_checked_form_model() {
// req: form/001 req: form/004 req: form/006
+10 -6
View File
@@ -1,4 +1,4 @@
use hemx_core::{Atom, Effect, EffectBatch, IntoEffect};
use hemx_core::{Atom, BuildFingerprint, Effect, EffectBatch, IntoEffect};
use serde::{de, Deserialize, Deserializer, Serialize};
use std::{
collections::{HashMap, HashSet},
@@ -421,8 +421,13 @@ impl SyncEffect {
/// Apply an optimistic projection now and carry the same ordinary batch in
/// the durable patch event so the framework sync runtime can replay it
/// after reload before acknowledgement.
pub fn durable(patch: FlatPatch, projection: EffectBatch) -> Self {
pub fn durable(
patch: FlatPatch,
projection: impl IntoEffect,
fingerprint: BuildFingerprint,
) -> Self {
patch.validate().expect("FlatPatch must remain valid");
let projection = projection.into_batch(fingerprint);
let projection_wire = projection
.to_wire()
.iter()
@@ -459,10 +464,9 @@ mod tests {
let projection = Effect::Emit {
name: "projected".into(),
payload: "card:1".into(),
}
.into_batch(hemx_core::BuildFingerprint(7));
let batch =
SyncEffect::durable(patch, projection).into_batch(hemx_core::BuildFingerprint(7));
};
let batch = SyncEffect::durable(patch, projection, hemx_core::BuildFingerprint(7))
.into_batch(hemx_core::BuildFingerprint(7));
assert_eq!(batch.ops.len(), 2);
assert!(matches!(&batch.ops[0], Effect::Emit { name, .. } if name == "projected"));
assert!(
+2 -2
View File
@@ -357,8 +357,8 @@ fn allowed_example_script(path: &Path, line: &str) -> bool {
&& line.contains(r#"<script src="/island.js" defer></script>"#))
|| (path.ends_with("examples/saas/templates/app_shell.heml")
&& line.contains(r#"<script src="/metrics.js" defer></script>"#))
|| (path.ends_with("examples/kanban/templates/sync_shell.heml")
&& line.contains(r#"<script src="/sync.js" defer></script>"#))
|| (path.ends_with("examples/kanban/templates/legacy_sync_fixture.heml")
&& line.contains(r#"<script type="module" src="/sync.js"></script>"#))
}
fn contains_inline_event_handler(line: &str) -> bool {
+2 -1
View File
@@ -130,6 +130,7 @@ fn pick_unused_port() -> Result<u16, ExitCode> {
fn start_html_examples_server(addr: &str) -> Result<HtmlExamplesServer, ExitCode> {
let mut child = Command::new("cargo")
.current_dir(workspace_root())
.args(["run", "-p", "hemx-html-examples"])
.env(
"HEMX_HTML_EXAMPLES_PORT",
@@ -1190,7 +1191,7 @@ fn run_test_plan() -> ExitCode {
"techdemo-unit-http",
["test", "-p", "hemx-techdemo", "--test", "e2e"],
),
Step::new("redgate", ["health", "--strict"]).tool("redgate"),
Step::new("redgate", ["refs"]).tool("redgate"),
];
if !budget.skip_browser {