docs(specs): archive local verification remediation
This commit is contained in:
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
workspace_dir="$(cd -- "$script_dir/../.." && pwd)"
|
||||
backend_dir="$workspace_dir/backend"
|
||||
snapshot_path="$workspace_dir/frontend/src/shared/api/spec/openapi.json"
|
||||
|
||||
if [[ ! -f "$snapshot_path" ]]; then
|
||||
echo "Missing committed OpenAPI snapshot: $snapshot_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node_major="$(node -p "process.versions.node.split('.')[0]")"
|
||||
if [[ "$node_major" != "22" ]]; then
|
||||
echo "Node 22 is required; found $(node --version)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
temporary_dir="$(mktemp -d "${TMPDIR:-/tmp}/wealth-ledger-runtime-openapi.XXXXXX")"
|
||||
backend_pid=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$backend_pid" ]] && kill -0 "$backend_pid" 2>/dev/null; then
|
||||
kill "$backend_pid" 2>/dev/null || true
|
||||
wait "$backend_pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$temporary_dir"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
port="$(node -e "const server = require('node:net').createServer(); server.listen(0, '127.0.0.1', () => { console.log(server.address().port); server.close(); });")"
|
||||
runtime_path="$temporary_dir/runtime.json"
|
||||
canonical_runtime_path="$temporary_dir/runtime.canonical.json"
|
||||
canonical_snapshot_path="$temporary_dir/snapshot.canonical.json"
|
||||
backend_log="$temporary_dir/backend.log"
|
||||
compile_log="$temporary_dir/compile.log"
|
||||
|
||||
if ! (
|
||||
cd "$backend_dir"
|
||||
./node_modules/.bin/tsc --project tsconfig.build.json --outDir "$temporary_dir/dist"
|
||||
) >"$compile_log" 2>&1; then
|
||||
echo "Temporary backend compilation failed:" >&2
|
||||
sed -n '1,160p' "$compile_log" >&2
|
||||
exit 1
|
||||
fi
|
||||
ln -s "$backend_dir/node_modules" "$temporary_dir/dist/node_modules"
|
||||
|
||||
(
|
||||
cd "$backend_dir"
|
||||
NODE_ENV=development \
|
||||
PORT="$port" \
|
||||
DATABASE_URL="file:$temporary_dir/runtime.db" \
|
||||
FRONTEND_ORIGIN="http://localhost:5173" \
|
||||
JWT_SECRET="runtime-openapi-check-secret-at-least-32" \
|
||||
TOKEN_ENCRYPTION_KEY="MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" \
|
||||
TBANK_GRPC_ENDPOINT="127.0.0.1:1" \
|
||||
TBANK_RATE_LIMIT_PER_SECOND=5 \
|
||||
LOG_LEVEL=error \
|
||||
node "$temporary_dir/dist/src/main.js"
|
||||
) >"$backend_log" 2>&1 &
|
||||
backend_pid="$!"
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
if node -e "fetch(process.argv[1]).then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))" "http://127.0.0.1:$port/api/docs-json"; then
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$backend_pid" 2>/dev/null; then
|
||||
echo "Backend stopped before exposing /api/docs-json:" >&2
|
||||
sed -n '1,160p' "$backend_log" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
|
||||
if ! node -e 'fetch(process.argv[1]).then(async (response) => { if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); require("node:fs").writeFileSync(process.argv[2], await response.text()); }).catch((error) => { console.error(`OpenAPI fetch failed: ${error.message}`); process.exit(1); })' "http://127.0.0.1:$port/api/docs-json" "$runtime_path"; then
|
||||
echo "Backend did not expose /api/docs-json within 15 seconds:" >&2
|
||||
sed -n '1,160p' "$backend_log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if node - "$runtime_path" "$snapshot_path" "$canonical_runtime_path" "$canonical_snapshot_path" <<'NODE'
|
||||
const { createHash } = require('node:crypto');
|
||||
const { readFileSync, writeFileSync } = require('node:fs');
|
||||
|
||||
function canonicalize(value) {
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, canonicalize(value[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const [runtimePath, snapshotPath, canonicalRuntimePath, canonicalSnapshotPath] =
|
||||
process.argv.slice(2);
|
||||
const documents = [runtimePath, snapshotPath].map((path) =>
|
||||
canonicalize(JSON.parse(readFileSync(path, 'utf8'))),
|
||||
);
|
||||
const serialized = documents.map((document) => `${JSON.stringify(document, null, 2)}\n`);
|
||||
writeFileSync(canonicalRuntimePath, serialized[0]);
|
||||
writeFileSync(canonicalSnapshotPath, serialized[1]);
|
||||
const hashes = serialized.map((value) =>
|
||||
createHash('sha256').update(value).digest('hex'),
|
||||
);
|
||||
console.log(`runtime SHA-256: ${hashes[0]}`);
|
||||
console.log(`snapshot SHA-256: ${hashes[1]}`);
|
||||
process.exitCode = hashes[0] === hashes[1] ? 0 : 1;
|
||||
NODE
|
||||
then
|
||||
comparison_status=0
|
||||
else
|
||||
comparison_status=$?
|
||||
fi
|
||||
|
||||
if [[ "$comparison_status" -ne 0 ]]; then
|
||||
echo "Runtime OpenAPI differs from the committed frontend snapshot:" >&2
|
||||
diff -u "$canonical_snapshot_path" "$canonical_runtime_path" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Runtime OpenAPI matches the committed frontend snapshot."
|
||||
Reference in New Issue
Block a user