feat(workout): record runtime asset integrity

Write and verify asset-integrity.tsv for the Workout mobile release kit using the actual hemx runtime path and SHA-256 digest, so mobile shell review has inspectable runtime integrity evidence.

req: examples/006
This commit is contained in:
slhx agent
2026-06-12 12:52:26 +02:00
parent f8f1a9aee5
commit 815392b232
4 changed files with 43 additions and 5 deletions
Generated
+3
View File
@@ -630,6 +630,9 @@ dependencies = [
[[package]] [[package]]
name = "hemx-xtask" name = "hemx-xtask"
version = "0.1.0" version = "0.1.0"
dependencies = [
"hemx-js",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
+2 -1
View File
@@ -56,7 +56,8 @@ The generated manifest records:
- app identity and version; - app identity and version;
- the production HTTPS origin used by Android and iOS shells; - the production HTTPS origin used by Android and iOS shells;
- `target/release/hemx-workout-example` as the server artifact; - `target/release/hemx-workout-example` as the server artifact;
- runtime assets served by the same release through `hemx_axum::runtime_js_path()`; - the exact runtime asset path and SHA-256 digest served by the same release;
- `asset-integrity.tsv` as a plain-text integrity receipt for mobile shell review;
- cache policy: release-scoped HTML/CSS/runtime assets only; - cache policy: release-scoped HTML/CSS/runtime assets only;
- offline truth policy: app-owned command/event/projection records, never DOM - offline truth policy: app-owned command/event/projection records, never DOM
patches or UI effect payloads; patches or UI effect payloads;
+3
View File
@@ -7,3 +7,6 @@ publish = false
[[bin]] [[bin]]
name = "hemx-ci" name = "hemx-ci"
path = "src/main.rs" path = "src/main.rs"
[dependencies]
hemx-js = { path = "../hemx-js" }
+35 -4
View File
@@ -373,6 +373,10 @@ fn write_workout_mobile_release(
config.out_dir.join("release-manifest.json"), config.out_dir.join("release-manifest.json"),
workout_mobile_manifest(config, blockers), workout_mobile_manifest(config, blockers),
)?; )?;
fs::write(
config.out_dir.join("asset-integrity.tsv"),
workout_asset_integrity(),
)?;
fs::write( fs::write(
config.out_dir.join("BLOCKERS.md"), config.out_dir.join("BLOCKERS.md"),
workout_mobile_blockers_md(blockers), workout_mobile_blockers_md(blockers),
@@ -409,7 +413,14 @@ fn verify_workout_mobile_release(
let android_path = config.out_dir.join("android/twa-release.json"); let android_path = config.out_dir.join("android/twa-release.json");
let ios_path = config.out_dir.join("ios/webview-release.json"); let ios_path = config.out_dir.join("ios/webview-release.json");
let blockers_path = config.out_dir.join("BLOCKERS.md"); let blockers_path = config.out_dir.join("BLOCKERS.md");
for path in [&manifest_path, &android_path, &ios_path, &blockers_path] { let integrity_path = config.out_dir.join("asset-integrity.tsv");
for path in [
&manifest_path,
&android_path,
&ios_path,
&blockers_path,
&integrity_path,
] {
if !path.exists() { if !path.exists() {
failures.push(format!("{} is missing", path.display())); failures.push(format!("{} is missing", path.display()));
} }
@@ -421,7 +432,9 @@ fn verify_workout_mobile_release(
&config.app_id, &config.app_id,
&config.version, &config.version,
&config.origin, &config.origin,
"hemx_axum::runtime_js_path()", hemx_js::RUNTIME_JS_PATH,
hemx_js::RUNTIME_JS_HASH,
"asset-integrity.tsv",
"app-owned command/event/projection records", "app-owned command/event/projection records",
"secrets and signing credentials stay outside the repo", "secrets and signing credentials stay outside the repo",
"rollback", "rollback",
@@ -466,6 +479,12 @@ fn verify_workout_mobile_release(
&mut failures, &mut failures,
); );
check_file_contains(
&integrity_path,
&[hemx_js::RUNTIME_JS_PATH, hemx_js::RUNTIME_JS_HASH, "sha256"],
&mut failures,
);
for blocker in mobile_external_blockers(config) { for blocker in mobile_external_blockers(config) {
check_file_contains(&manifest_path, &[&blocker], &mut failures); check_file_contains(&manifest_path, &[&blocker], &mut failures);
check_file_contains(&blockers_path, &[&blocker], &mut failures); check_file_contains(&blockers_path, &[&blocker], &mut failures);
@@ -496,13 +515,23 @@ fn check_file_contains(path: &Path, needles: &[&str], failures: &mut Vec<String>
} }
} }
fn workout_asset_integrity() -> String {
format!(
"path\talgorithm\tdigest\n{}\tsha256\t{}\n",
hemx_js::RUNTIME_JS_PATH,
hemx_js::RUNTIME_JS_HASH
)
}
fn workout_mobile_manifest(config: &WorkoutMobileConfig, blockers: &[String]) -> String { fn workout_mobile_manifest(config: &WorkoutMobileConfig, blockers: &[String]) -> String {
format!( format!(
"{{\n \"app_id\": \"{}\",\n \"name\": \"{}\",\n \"version\": \"{}\",\n \"origin\": \"{}\",\n \"server_binary\": \"target/release/hemx-workout-example\",\n \"runtime_asset_path\": \"served by hemx_axum::runtime_js_path() from the same release\",\n \"cache_policy\": \"cache only release-scoped HTML/CSS/runtime assets; never store DOM patches or UI effects as truth\",\n \"state_policy\": \"app-owned command/event/projection records\",\n \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\n \"environment_boundary\": \"public mobile shell config lives here; secrets and signing credentials stay outside the repo\",\n \"rollback\": \"redeploy the previous server binary and matching mobile shell metadata; rebuild store artifacts with the previous version/signing inputs\",\n \"android\": \"android/twa-release.json\",\n \"ios\": \"ios/webview-release.json\",\n \"external_blockers\": [{}]\n}}\n", "{{\n \"app_id\": \"{}\",\n \"name\": \"{}\",\n \"version\": \"{}\",\n \"origin\": \"{}\",\n \"server_binary\": \"target/release/hemx-workout-example\",\n \"runtime_asset_path\": \"{}\",\n \"runtime_asset_sha256\": \"{}\",\n \"asset_integrity\": \"asset-integrity.tsv\",\n \"cache_policy\": \"cache only release-scoped HTML/CSS/runtime assets; never store DOM patches or UI effects as truth\",\n \"state_policy\": \"app-owned command/event/projection records\",\n \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\n \"environment_boundary\": \"public mobile shell config lives here; secrets and signing credentials stay outside the repo\",\n \"rollback\": \"redeploy the previous server binary and matching mobile shell metadata; rebuild store artifacts with the previous version/signing inputs\",\n \"android\": \"android/twa-release.json\",\n \"ios\": \"ios/webview-release.json\",\n \"external_blockers\": [{}]\n}}\n",
json_escape(&config.app_id), json_escape(&config.app_id),
json_escape(&config.app_name), json_escape(&config.app_name),
json_escape(&config.version), json_escape(&config.version),
json_escape(&config.origin), json_escape(&config.origin),
json_escape(hemx_js::RUNTIME_JS_PATH),
json_escape(hemx_js::RUNTIME_JS_HASH),
json_string_list(blockers), json_string_list(blockers),
) )
} }
@@ -936,7 +965,9 @@ mod tests {
let manifest = workout_mobile_manifest(&config, &blockers); let manifest = workout_mobile_manifest(&config, &blockers);
assert!(manifest.contains("target/release/hemx-workout-example")); assert!(manifest.contains("target/release/hemx-workout-example"));
assert!(manifest.contains("hemx_axum::runtime_js_path()")); assert!(manifest.contains(hemx_js::RUNTIME_JS_PATH));
assert!(manifest.contains(hemx_js::RUNTIME_JS_HASH));
assert!(manifest.contains("asset-integrity.tsv"));
assert!(manifest.contains("app-owned command/event/projection records")); assert!(manifest.contains("app-owned command/event/projection records"));
assert!(manifest.contains( assert!(manifest.contains(
"\"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"]" "\"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"]"