Compare commits
10 Commits
c793de8224
...
e7211df4c5
| Author | SHA1 | Date | |
|---|---|---|---|
| e7211df4c5 | |||
| a04773d016 | |||
| 48d6ea31be | |||
| 23b1d75f7f | |||
| 025d9c1563 | |||
| 90e8c3ced5 | |||
| 6f58453aae | |||
| 4fc5e53adf | |||
| 4e0d9ebaff | |||
| ef8e38adf8 |
@@ -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
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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;
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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\":["));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user