Mercurial
changeset 228:62f9cb40d10d
[Merge] Merging conflicts
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 16:44:06 -0700 |
| parents | 8bb0ac8f4587 (diff) e82b80b24012 (current diff) |
| children | 7795e3149540 |
| files | MODULE.bazel.lock |
| diffstat | 43 files changed, 5224 insertions(+), 801 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/AGENT.md Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,21 @@ +# Agent instructions + +## Bazel is the project interface + +All project execution must happen through Bazel. Use existing `bazel build`, +`bazel test`, and `bazel run` targets instead of invoking compiled binaries, +language runtimes, package managers, generated scripts, or multiple services +manually. + +When a workflow needs several processes or commands, add a Bazel target that +owns their startup, runfiles, supervision, shutdown, and exit status. The user +should need one `bazel run` command. + +For hg-web development, run the complete local stack with: + +```bash +bazel run //hg-web:dev +``` + +Add or update the smallest relevant Bazel target whenever code cannot be built, +tested, run, bundled, or orchestrated through Bazel.
--- a/MODULE.bazel Sat Feb 28 21:04:43 2026 -0800 +++ b/MODULE.bazel Sun Aug 02 16:44:06 2026 -0700 @@ -2,6 +2,8 @@ bazel_dep(name = "platforms", version = "1.0.0") bazel_dep(name = "bazel_skylib", version = "1.8.2") bazel_dep(name = "rules_shell", version = "0.6.1") +bazel_dep(name = "aspect_rules_js", version = "2.9.2", dev_dependency = True) +bazel_dep(name = "rules_nodejs", version = "6.7.3", dev_dependency = True) bazel_dep(name = "openssl", version = "3.3.1.bcr.7") bazel_dep(name = "rules_foreign_cc", version = "0.14.0") bazel_dep(name = "buildifier_prebuilt", version = "7.1.2", dev_dependency = True) @@ -13,6 +15,36 @@ ) http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file") +http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "playwright_chromium_linux", + urls = ["https://playwright.azureedge.net/builds/chromium/1140/chromium-linux.zip"], + sha256 = "e78cda52fa7e847abcd36a48ffabda43e71e2220a3e9a398f7c9a179529570a5", + strip_prefix = "chrome-linux", + build_file_content = """ +exports_files(["chrome"]) + +filegroup( + name = "chromium", + srcs = glob(["**"], exclude = ["BUILD.bazel"]), + visibility = ["//visibility:public"], +) +""", +) + +node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") +node.toolchain(node_version = "20.18.0") +use_repo(node, "nodejs") + +npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm") +npm.npm_translate_lock( + name = "hg_web_npm", + data = ["//hg-web/e2e:package.json"], + npm_package_lock = "//hg-web/e2e:package-lock.json", + pnpm_lock = "//hg-web/e2e:pnpm-lock.yaml", +) +use_repo(npm, "hg_web_npm") # Bun http_file( @@ -31,8 +63,10 @@ sha256 = "cf0ed0a920799d576ffde4e0cae66d732bf23c2530407f26f59c7831dffe1f0e", ) +# Bring in Python support +bazel_dep(name = "rules_python", version = "1.7.0") + # Bring in pip support -# bazel_dep(name = "rules_python", version = "1.7.0") # use_extension("@rules_python//python/extensions:pip.bzl", "pip") # pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")
--- a/MODULE.bazel.lock Sat Feb 28 21:04:43 2026 -0800 +++ b/MODULE.bazel.lock Sun Aug 02 16:44:06 2026 -0700 @@ -1,5 +1,5 @@ { - "lockFileVersion": 18, + "lockFileVersion": 28, "registryFileHashes": { "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", @@ -9,7 +9,17 @@ "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16", "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", - "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/source.json": "9be551b8d4e3ef76875c0d744b5d6a504a27e3ae67bc6b28f46415fd2d2957da", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16", + "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1", + "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c", + "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896", + "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", + "https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e", + "https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1", + "https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4", + "https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.2/MODULE.bazel": "30dfabbfae0139b1f0036e01c201dd4c0167da3017f0b7ef3820d78e07622989", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.2/source.json": "89d8e5d7088ae33972733099b756dae71e1647ae684ab50b26adfa853c506d01", @@ -19,17 +29,23 @@ "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.3/MODULE.bazel": "37c764292861c2f70314efa9846bb6dbb44fc0308903b3285da6528305450183", "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.3/source.json": "605086bbc197743a0d360f7ddc550a1d4dfa0441bc807236e17170f636153348", "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", + "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101", "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", + "https://bcr.bazel.build/modules/bazel_features/1.23.0/MODULE.bazel": "fd1ac84bc4e97a5a0816b7fd7d4d4f6d837b0047cf4cbd81652d616af3a6591a", + "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", + "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", + "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", "https://bcr.bazel.build/modules/bazel_features/1.39.0/MODULE.bazel": "28739425c1fc283c91931619749c832b555e60bcd1010b40d8441ce0a5cf726d", - "https://bcr.bazel.build/modules/bazel_features/1.39.0/source.json": "f63cbeb4c602098484d57001e5a07d31cb02bbccde9b5e2c9bf0b29d05283e93", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", + "https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080", + "https://bcr.bazel.build/modules/bazel_features/1.42.1/source.json": "fcd4396b2df85f64f2b3bb436ad870793ecf39180f1d796f913cc9276d355309", "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d", @@ -50,18 +66,23 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb", "https://bcr.bazel.build/modules/buildifier_prebuilt/7.1.2/MODULE.bazel": "d18b017dddf219626ea5b04028ff0db2397655fbdaa9d9258a6443ddafb303ab", "https://bcr.bazel.build/modules/buildifier_prebuilt/7.1.2/source.json": "5fb3a2433f6508f4b23934eaa2986d604ae65aff95d7476d1273bf5cd434eedc", - "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", - "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", + "https://bcr.bazel.build/modules/buildozer/8.5.1/MODULE.bazel": "a35d9561b3fc5b18797c330793e99e3b834a473d5fbd3d7d7634aafc9bdb6f8f", + "https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd", "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", - "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/source.json": "41e9e129f80d8c8bf103a7acc337b76e54fad1214ac0a7084bf24f4cd924b8b4", "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", + "https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108", + "https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46", + "https://bcr.bazel.build/modules/googletest/1.17.0/source.json": "38e4454b25fc30f15439c0378e57909ab1fd0a443158aa35aec685da727cd713", "https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f", "https://bcr.bazel.build/modules/jq.bzl/0.1.0/source.json": "746bf13cac0860f091df5e4911d0c593971cd8796b5ad4e809b2f8e133eee3d5", "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", - "https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9", "https://bcr.bazel.build/modules/openssl/3.3.1.bcr.7/MODULE.bazel": "dcb8181b28242d40ef854248f2929f3118dec18e68080e8ae3c0b7444316745b", "https://bcr.bazel.build/modules/openssl/3.3.1.bcr.7/source.json": "339b52ba88f2d4bd7a55822bf1c790b7c00a09e0f8d323208b9e67086f22bdb6", "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92", @@ -73,6 +94,7 @@ "https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615", "https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814", "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d", + "https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc", "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580", "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96", "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", @@ -80,19 +102,26 @@ "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d", "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92", - "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", - "https://bcr.bazel.build/modules/protobuf/29.0/source.json": "b857f93c796750eef95f0d61ee378f3420d00ee1dd38627b27193aa482f4f981", + "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d", + "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42", + "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79", "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", - "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/source.json": "be4789e951dd5301282729fe3d4938995dc4c1a81c2ff150afc9f1b0504c6022", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680", "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", - "https://bcr.bazel.build/modules/re2/2023-09-01/source.json": "e044ce89c2883cd957a2969a43e79f7752f9656f6b20050b62f90ede21ec6eb4", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4", + "https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa", "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e", + "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a", + "https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68", + "https://bcr.bazel.build/modules/rules_apple/4.1.0/source.json": "8ee81e1708756f81b343a5eb2b2f0b953f1d25c4ab3d4a68dc02754872e80715", "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", - "https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac", "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a", @@ -101,39 +130,39 @@ "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", + "https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0", "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", + "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c", + "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", - "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc", + "https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84", + "https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", + "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.14.0/MODULE.bazel": "56fb9a239503bab4183d06ba6cabb01cd73aae296ab499085b9193624a8a66e2", "https://bcr.bazel.build/modules/rules_foreign_cc/0.14.0/source.json": "64ccb6c4bff8afc336a24af2487b4557b8d2b13f981f2d8190983bc196b36a68", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", - "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e", "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", - "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", "https://bcr.bazel.build/modules/rules_java/6.3.0/MODULE.bazel": "a97c7678c19f236a956ad260d59c86e10a463badb7eb2eda787490f4c969b963", - "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6", "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", - "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2", "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", - "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615", - "https://bcr.bazel.build/modules/rules_java/8.14.0/source.json": "8a88c4ca9e8759da53cddc88123880565c520503321e2566b4e33d0287a3d4bc", "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", + "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", + "https://bcr.bazel.build/modules/rules_java/9.1.0/MODULE.bazel": "ee63f27e36a3fada80342869361182f120a9819c74320e8e65b1e04ba0cd7a9d", + "https://bcr.bazel.build/modules/rules_java/9.1.0/source.json": "da589573c1dee2c9ac4a568b301269a2e8191110ff0345c1a959fa7ea6c4dfd6", "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", - "https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d", - "https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4", "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", - "https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197", - "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5", "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", @@ -150,17 +179,22 @@ "https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a", "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", + "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", - "https://bcr.bazel.build/modules/rules_proto/7.0.2/source.json": "1e5e7260ae32ef4f2b52fd1d0de8d03b606a44c91b694d2f1afb1d3b28a48ce1", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e", "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", + "https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937", "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", - "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", "https://bcr.bazel.build/modules/rules_python/1.1.0/MODULE.bazel": "57e01abae22956eb96d891572490d20e07d983e0c065de0b2170cafe5053e788", + "https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13", + "https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6", + "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8", "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", @@ -169,14 +203,20 @@ "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b", "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c", + "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca", + "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046", + "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd", + "https://bcr.bazel.build/modules/rules_swift/3.1.2/MODULE.bazel": "72c8f5cf9d26427cee6c76c8e3853eb46ce6b0412a081b2b6db6e8ad56267400", + "https://bcr.bazel.build/modules/rules_swift/3.1.2/source.json": "e85761f3098a6faf40b8187695e3de6d97944e98abd0d8ce579cb2daf6319a66", "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", - "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef", "https://bcr.bazel.build/modules/stardoc/0.6.2/MODULE.bazel": "7060193196395f5dd668eda046ccbeacebfd98efc77fed418dbe2b82ffaa39fd", "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", - "https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7", "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b", "https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468", "https://bcr.bazel.build/modules/tar.bzl/0.2.1/source.json": "600ac6ff61744667a439e7b814ae59c1f29632c3984fccf8000c64c9db8d7bb6", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", @@ -190,13 +230,110 @@ }, "selectedYankedVersions": {}, "moduleExtensions": { + "@@aspect_rules_js+//npm:extensions.bzl%pnpm": { + "general": { + "bzlTransitiveDigest": "A3wObJgLD5IcmyBKFyx9nfycX4VosrUq4UtGAHdyZ8M=", + "usagesDigest": "UCPGCdTkoflpzf0ShZ9hsk2zN6bbQlRvpRRZJnniDDc=", + "recordedInputs": [ + "REPO_MAPPING:aspect_bazel_lib+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:aspect_bazel_lib+,bazel_tools bazel_tools", + "REPO_MAPPING:aspect_bazel_lib+,tar.bzl tar.bzl+", + "REPO_MAPPING:aspect_rules_js+,aspect_bazel_lib aspect_bazel_lib+", + "REPO_MAPPING:aspect_rules_js+,aspect_rules_js aspect_rules_js+", + "REPO_MAPPING:aspect_rules_js+,aspect_tools_telemetry_report aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report", + "REPO_MAPPING:aspect_rules_js+,bazel_features bazel_features+", + "REPO_MAPPING:aspect_rules_js+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_rules_js+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:aspect_rules_js+,bazel_tools bazel_tools", + "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", + "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version", + "REPO_MAPPING:bazel_lib+,bazel_tools bazel_tools", + "REPO_MAPPING:tar.bzl+,aspect_bazel_lib aspect_bazel_lib+", + "REPO_MAPPING:tar.bzl+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:tar.bzl+,tar.bzl tar.bzl+" + ], + "generatedRepoSpecs": { + "pnpm": { + "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_rule", + "attributes": { + "package": "pnpm", + "version": "8.15.9", + "root_package": "", + "link_workspace": "", + "link_packages": {}, + "integrity": "sha512-SZQ0ydj90aJ5Tr9FUrOyXApjOrzuW7Fee13pDzL0e1E6ypjNXP0AHDHw20VLw4BO3M1XhQHkyik6aBYWa72fgQ==", + "url": "", + "commit": "", + "patch_args": [ + "-p0" + ], + "patches": [], + "custom_postinstall": "", + "npm_auth": "", + "npm_auth_basic": "", + "npm_auth_username": "", + "npm_auth_password": "", + "lifecycle_hooks": [], + "extra_build_content": "load(\"@aspect_rules_js//js:defs.bzl\", \"js_binary\")\njs_binary(name = \"pnpm\", data = glob([\"package/**\"]), entry_point = \"package/dist/pnpm.cjs\", visibility = [\"//visibility:public\"])", + "extract_full_archive": true, + "exclude_package_contents": [] + } + }, + "pnpm__links": { + "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_links", + "attributes": { + "package": "pnpm", + "version": "8.15.9", + "dev": false, + "root_package": "", + "link_packages": {}, + "deps": {}, + "transitive_closure": {}, + "lifecycle_build_target": false, + "lifecycle_hooks_env": [], + "lifecycle_hooks_execution_requirements": [ + "no-sandbox" + ], + "lifecycle_hooks_use_default_shell_env": false, + "bins": {}, + "package_visibility": [ + "//visibility:public" + ], + "exclude_package_contents": [] + } + } + } + } + }, + "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { + "general": { + "bzlTransitiveDigest": "cl5A2O84vDL6Tt+Qga8FCj1DUDGqn+e7ly5rZ+4xvcc=", + "usagesDigest": "TQSuPERI87Z4Alo1eOWeUR3NGo5f3txcCOd4tpsGXmw=", + "recordedInputs": [ + "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+" + ], + "generatedRepoSpecs": { + "aspect_tools_telemetry_report": { + "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository", + "attributes": { + "deps": { + "aspect_rules_js": "2.9.2", + "aspect_tools_telemetry": "0.3.3" + } + } + } + } + } + }, "@@buildifier_prebuilt+//:defs.bzl%buildifier_prebuilt_deps_extension": { "general": { - "bzlTransitiveDigest": "YPkknXQDbryIvBRlzT1v/mQ8v4KvZhsmAmxP1pH5A78=", + "bzlTransitiveDigest": "HljGbKDagP11Zj2xu7uXH4csvPiytXXlNpFDe1PeuTs=", "usagesDigest": "BWWW2nG29mUymMGpRgfP+nxndKWaf1fCZZ33EDPaVIc=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:buildifier_prebuilt+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:buildifier_prebuilt+,bazel_tools bazel_tools" + ], "generatedRepoSpecs": { "buildifier_darwin_amd64": { "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", @@ -314,28 +451,14 @@ "assets_json": "[{\"arch\":\"amd64\",\"name\":\"buildifier\",\"platform\":\"darwin\",\"sha256\":\"687c49c318fb655970cf716eed3c7bfc9caeea4f2931a2fd36593c458de0c537\",\"version\":\"v7.1.2\"},{\"arch\":\"arm64\",\"name\":\"buildifier\",\"platform\":\"darwin\",\"sha256\":\"d0909b645496608fd6dfc67f95d9d3b01d90736d7b8c8ec41e802cb0b7ceae7c\",\"version\":\"v7.1.2\"},{\"arch\":\"amd64\",\"name\":\"buildifier\",\"platform\":\"linux\",\"sha256\":\"28285fe7e39ed23dc1a3a525dfcdccbc96c0034ff1d4277905d2672a71b38f13\",\"version\":\"v7.1.2\"},{\"arch\":\"arm64\",\"name\":\"buildifier\",\"platform\":\"linux\",\"sha256\":\"c22a44eee37b8927167ee6ee67573303f4e31171e7ec3a8ea021a6a660040437\",\"version\":\"v7.1.2\"},{\"arch\":\"amd64\",\"name\":\"buildifier\",\"platform\":\"windows\",\"sha256\":\"a8331515019d8d3e01baa1c76fda19e8e6e3e05532d4b0bce759bd759d0cafb7\",\"version\":\"v7.1.2\"},{\"arch\":\"amd64\",\"name\":\"buildozer\",\"platform\":\"darwin\",\"sha256\":\"90da5cf4f7db73007977a8c6bec23fa7022265978187e1da8df5edc91daf6ee1\",\"version\":\"v7.1.2\"},{\"arch\":\"arm64\",\"name\":\"buildozer\",\"platform\":\"darwin\",\"sha256\":\"bedff301bc51f04da46d2c8900c1753032ea88485af375a9f1b7bed0915558e0\",\"version\":\"v7.1.2\"},{\"arch\":\"amd64\",\"name\":\"buildozer\",\"platform\":\"linux\",\"sha256\":\"8d5c459ab21b411b8be059a8bdf59f0d3eabf9dff943d5eccb80e36e525cc09d\",\"version\":\"v7.1.2\"},{\"arch\":\"arm64\",\"name\":\"buildozer\",\"platform\":\"linux\",\"sha256\":\"a00d1790e8c92c5022d83e345d6629506836d73c23c5338d5f777589bfaed02d\",\"version\":\"v7.1.2\"},{\"arch\":\"amd64\",\"name\":\"buildozer\",\"platform\":\"windows\",\"sha256\":\"3a650e10f07787760889d7e5694924d881265ae2384499fd59ada7c39c02366e\",\"version\":\"v7.1.2\"}]" } } - }, - "recordedRepoMappingEntries": [ - [ - "buildifier_prebuilt+", - "bazel_skylib", - "bazel_skylib+" - ], - [ - "buildifier_prebuilt+", - "bazel_tools", - "bazel_tools" - ] - ] + } } }, "@@emsdk+//:emscripten_cache.bzl%emscripten_cache": { "general": { "bzlTransitiveDigest": "uqDvXmpTNqW4+ie/Fk+xC3TrFrKvL+9hNtoP51Kt2oo=", "usagesDigest": "Jtpnpp8lxjZfLQo3Tt7L62pRRTIcHPBLoHYHYfkQrxM=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [], "generatedRepoSpecs": { "emscripten_cache": { "repoRuleId": "@@emsdk+//:emscripten_cache.bzl%_emscripten_cache_repository", @@ -344,17 +467,27 @@ "targets": [] } } - }, - "recordedRepoMappingEntries": [] + } } }, "@@emsdk+//:emscripten_deps.bzl%emscripten_deps": { "general": { - "bzlTransitiveDigest": "1X4T1VjJD8ivkANJWOhhthe3duPo3E6amm827VP4Gzk=", + "bzlTransitiveDigest": "+1O9FmrwgJwK/ywCKhwouNuu7NWC/hfkkzjuGp/1c9Q=", "usagesDigest": "f1E1WkCSGt50uwBJp/heJk2R/iAm39ixgGS93wpdywA=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", + "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version", + "REPO_MAPPING:emsdk+,bazel_tools bazel_tools", + "REPO_MAPPING:emsdk+,rules_cc rules_cc+", + "REPO_MAPPING:protobuf+,proto_bazel_features bazel_features+", + "REPO_MAPPING:rules_cc+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:rules_cc+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_cc+,cc_compatibility_proxy rules_cc++compatibility_proxy+cc_compatibility_proxy", + "REPO_MAPPING:rules_cc+,com_google_protobuf protobuf+", + "REPO_MAPPING:rules_cc+,platforms platforms", + "REPO_MAPPING:rules_cc+,rules_cc rules_cc+", + "REPO_MAPPING:rules_cc++compatibility_proxy+cc_compatibility_proxy,rules_cc rules_cc+" + ], "generatedRepoSpecs": { "emscripten_bin_linux": { "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", @@ -406,48 +539,39 @@ "url": "https://storage.googleapis.com/webassembly/emscripten-releases-builds/win/aaa43392544d695232b70eda706d751f18980c2a/wasm-binaries.zip" } } - }, - "recordedRepoMappingEntries": [ - [ - "emsdk+", - "bazel_tools", - "bazel_tools" - ], - [ - "emsdk+", - "rules_cc", - "rules_cc+" - ], - [ - "rules_cc+", - "bazel_tools", - "bazel_tools" - ], - [ - "rules_cc+", - "cc_compatibility_proxy", - "rules_cc++compatibility_proxy+cc_compatibility_proxy" - ], - [ - "rules_cc+", - "rules_cc", - "rules_cc+" - ], - [ - "rules_cc++compatibility_proxy+cc_compatibility_proxy", - "rules_cc", - "rules_cc+" - ] - ] + } + } + }, + "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": { + "general": { + "bzlTransitiveDigest": "NRXra7941UfmNUyIxnLt82V5hULluVGL2nBsijTl4j4=", + "usagesDigest": "D1r3lfzMuUBFxgG8V6o0bQTLMk3GkaGOaPzw53wrwyw=", + "recordedInputs": [ + "REPO_MAPPING:pybind11_bazel+,bazel_tools bazel_tools", + "FILE:@@pybind11_bazel+//MODULE.bazel e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34" + ], + "generatedRepoSpecs": { + "pybind11": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@pybind11_bazel+//:pybind11-BUILD.bazel", + "strip_prefix": "pybind11-2.12.0", + "urls": [ + "https://github.com/pybind/pybind11/archive/v2.12.0.zip" + ] + } + } + } } }, "@@rules_foreign_cc+//foreign_cc:extensions.bzl%tools": { "general": { - "bzlTransitiveDigest": "bzYvbsHj2ct8D8fQBqNNAJqAjmx6oxXp0japlQvDLjo=", + "bzlTransitiveDigest": "G54TxUUn6vxT4n5eIpNwbycPtXtiR354MSgHTcQAnHI=", "usagesDigest": "Eyh4mAOi6L+Nn/lY/wQBJclQrmBnWdQM+B4lZeq6azA=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:rules_foreign_cc+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_foreign_cc+,rules_foreign_cc rules_foreign_cc+" + ], "generatedRepoSpecs": { "rules_foreign_cc_framework_toolchain_linux": { "repoRuleId": "@@rules_foreign_cc+//foreign_cc/private/framework:toolchain.bzl%framework_toolchain_repository", @@ -810,28 +934,16 @@ "tool": "ninja" } } - }, - "recordedRepoMappingEntries": [ - [ - "rules_foreign_cc+", - "bazel_tools", - "bazel_tools" - ], - [ - "rules_foreign_cc+", - "rules_foreign_cc", - "rules_foreign_cc+" - ] - ] + } } }, "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { "general": { - "bzlTransitiveDigest": "OlvsB0HsvxbR8ZN+J9Vf00X/+WVz/Y/5Xrq2LgcVfdo=", + "bzlTransitiveDigest": "+Kp6j204mBZ3mxlIDDR0gBoP45BZ4jYRhRAcB8sU0qc=", "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools" + ], "generatedRepoSpecs": { "com_github_jetbrains_kotlin_git": { "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", @@ -879,23 +991,14 @@ ] } } - }, - "recordedRepoMappingEntries": [ - [ - "rules_kotlin+", - "bazel_tools", - "bazel_tools" - ] - ] + } } }, "@@rules_nodejs+//nodejs:extensions.bzl%node": { "general": { "bzlTransitiveDigest": "4pUxCNc22K4I+6+4Nxu52Hur12tFRfa1JMsN5mdDv60=", - "usagesDigest": "1PdljUSmnJF0C3nTMWMdndAlDqMjOzQHlOp2TAszTxk=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "usagesDigest": "aOq2mNhv/K35rllf0LkQnk575K/CZNf1RTfrNe1kDkY=", + "recordedInputs": [], "generatedRepoSpecs": { "nodejs_linux_amd64": { "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories", @@ -1019,17 +1122,31 @@ "user_node_repository_name": "nodejs" } } - }, - "recordedRepoMappingEntries": [] + } } }, "@@rules_python+//python/extensions:config.bzl%config": { "general": { - "bzlTransitiveDigest": "xaCns8Qt+8bJqVLy8r6nc/eL2AjEIX/vOdjqoh5xYac=", + "bzlTransitiveDigest": "dzD8Q2YmrP3fz8saWLHPmlwPLO91ImtTmP/c9JKTStM=", "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_python+,pypi__build rules_python++config+pypi__build", + "REPO_MAPPING:rules_python+,pypi__click rules_python++config+pypi__click", + "REPO_MAPPING:rules_python+,pypi__colorama rules_python++config+pypi__colorama", + "REPO_MAPPING:rules_python+,pypi__importlib_metadata rules_python++config+pypi__importlib_metadata", + "REPO_MAPPING:rules_python+,pypi__installer rules_python++config+pypi__installer", + "REPO_MAPPING:rules_python+,pypi__more_itertools rules_python++config+pypi__more_itertools", + "REPO_MAPPING:rules_python+,pypi__packaging rules_python++config+pypi__packaging", + "REPO_MAPPING:rules_python+,pypi__pep517 rules_python++config+pypi__pep517", + "REPO_MAPPING:rules_python+,pypi__pip rules_python++config+pypi__pip", + "REPO_MAPPING:rules_python+,pypi__pip_tools rules_python++config+pypi__pip_tools", + "REPO_MAPPING:rules_python+,pypi__pyproject_hooks rules_python++config+pypi__pyproject_hooks", + "REPO_MAPPING:rules_python+,pypi__setuptools rules_python++config+pypi__setuptools", + "REPO_MAPPING:rules_python+,pypi__tomli rules_python++config+pypi__tomli", + "REPO_MAPPING:rules_python+,pypi__wheel rules_python++config+pypi__wheel", + "REPO_MAPPING:rules_python+,pypi__zipp rules_python++config+pypi__zipp" + ], "generatedRepoSpecs": { "rules_python_internal": { "repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo", @@ -1173,98 +1290,17 @@ "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" } } - }, - "recordedRepoMappingEntries": [ - [ - "rules_python+", - "bazel_tools", - "bazel_tools" - ], - [ - "rules_python+", - "pypi__build", - "rules_python++config+pypi__build" - ], - [ - "rules_python+", - "pypi__click", - "rules_python++config+pypi__click" - ], - [ - "rules_python+", - "pypi__colorama", - "rules_python++config+pypi__colorama" - ], - [ - "rules_python+", - "pypi__importlib_metadata", - "rules_python++config+pypi__importlib_metadata" - ], - [ - "rules_python+", - "pypi__installer", - "rules_python++config+pypi__installer" - ], - [ - "rules_python+", - "pypi__more_itertools", - "rules_python++config+pypi__more_itertools" - ], - [ - "rules_python+", - "pypi__packaging", - "rules_python++config+pypi__packaging" - ], - [ - "rules_python+", - "pypi__pep517", - "rules_python++config+pypi__pep517" - ], - [ - "rules_python+", - "pypi__pip", - "rules_python++config+pypi__pip" - ], - [ - "rules_python+", - "pypi__pip_tools", - "rules_python++config+pypi__pip_tools" - ], - [ - "rules_python+", - "pypi__pyproject_hooks", - "rules_python++config+pypi__pyproject_hooks" - ], - [ - "rules_python+", - "pypi__setuptools", - "rules_python++config+pypi__setuptools" - ], - [ - "rules_python+", - "pypi__tomli", - "rules_python++config+pypi__tomli" - ], - [ - "rules_python+", - "pypi__wheel", - "rules_python++config+pypi__wheel" - ], - [ - "rules_python+", - "pypi__zipp", - "rules_python++config+pypi__zipp" - ] - ] + } } }, "@@rules_python+//python/uv:uv.bzl%uv": { "general": { - "bzlTransitiveDigest": "N8SCcKcL6KnzBLApxvY2jR9vhXjA2VCBZMLZfY3sDRA=", + "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=", "usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_python+,platforms platforms" + ], "generatedRepoSpecs": { "uv": { "repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo", @@ -1284,28 +1320,14 @@ "toolchain_target_settings": {} } } - }, - "recordedRepoMappingEntries": [ - [ - "rules_python+", - "bazel_tools", - "bazel_tools" - ], - [ - "rules_python+", - "platforms", - "platforms" - ] - ] + } } }, "@@yq.bzl+//yq:extensions.bzl%yq": { "general": { "bzlTransitiveDigest": "61Uz+o5PnlY0jJfPZEUNqsKxnM/UCLeWsn5VVCc8u5Y=", "usagesDigest": "1LyF9N6pw6Si4IXKugZfKYnx8CNnZniv05B2ubvTocg=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [], "generatedRepoSpecs": { "yq_darwin_amd64": { "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", @@ -1369,9 +1391,10 @@ "user_repository_name": "yq" } } - }, - "recordedRepoMappingEntries": [] + } } } - } + }, + "facts": {}, + "factsVersions": {} }
--- a/hg-web/BUILD Sat Feb 28 21:04:43 2026 -0800 +++ b/hg-web/BUILD Sun Aug 02 16:44:06 2026 -0700 @@ -1,4 +1,5 @@ load("@rules_cc//cc:cc_binary.bzl", "cc_binary") +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle", "bun_bundle") # Source files @@ -58,6 +59,7 @@ srcs = ["main.c"], deps = ["//seobeo:seobeo"], data = [":all_assets"], + visibility = ["//hg-web:__subpackages__"], ) cc_binary( @@ -67,7 +69,30 @@ data = [":all_assets"], ) +sh_binary( + name = "dev", + srcs = ["dev.sh"], + data = [ + ":hg_web_server", + "@bazel_tools//tools/bash/runfiles", + ], +) + +test_suite( + name = "tests", + tests = ["//hg-web/e2e:app_e2e_test"], +) + bundle( name = "hg_web_server_bundle", binary = ":hg_web_server", ) + +sh_binary( + name = "deploy", + srcs = ["deploy.sh"], + data = [ + ":hg_web_server_bundle", + "@bazel_tools//tools/bash/runfiles", + ], +)
--- a/hg-web/README.md Sat Feb 28 21:04:43 2026 -0800 +++ b/hg-web/README.md Sun Aug 02 16:44:06 2026 -0700 @@ -1,34 +1,305 @@ # hg-web -A web-based Mercurial repository browser. Provides a GitHub-style interface for browsing files, viewing code with syntax highlighting, and reading markdown documentation. +A custom Mercurial forge and repository browser. The application keeps the +networking and HTTP stack in C with Seobeo, while the hand-drawn web interface +is implemented in React and TypeScript. + +## Wiki tree -## Features +```text +hg-web +├── Runtime +│ ├── hg_web_server :6970 +│ │ ├── React application and static assets +│ │ ├── repository browsing API +│ │ ├── commit graph API +│ │ └── Mercurial wire-protocol proxy +│ └── hg serve :4444 +│ ├── repository files and graph data +│ └── Mercurial pull/push protocol +├── Backend +│ ├── main.c +│ └── ../seobeo +│ ├── s_network.c +│ ├── s_web.c +│ ├── s_http_client.c +│ └── s_ssl.c +├── Frontend +│ └── src +│ ├── main.tsx +│ ├── components +│ │ ├── app.tsx +│ │ ├── directory-browser.tsx +│ │ ├── graph.tsx +│ │ ├── header.tsx +│ │ ├── footer.tsx +│ │ ├── theme.tsx +│ │ └── repo-browser.tsx +│ ├── index.html +│ ├── index.css +│ ├── base.css +│ └── custom pencil, panda, and icon assets +├── Build +│ ├── BUILD +│ ├── ../gui_ze/gui_ze.bzl +│ ├── ../markdown_converter +│ └── ../third_party/highlight +└── Operations + └── deploy.sh +``` + +`repo-browser.tsx` is an older standalone browser implementation. The active +entry point is `main.tsx`, which renders `app.tsx`; `app.tsx` embeds +`directory-browser.tsx`. + +## Runtime topology -- Browse repository files and directories -- View code files with syntax highlighting (highlight.js) -- Render markdown files with WASM-based converter -- Dark/light theme support with system preference detection -- Prefetch on hover for faster navigation +```text +Browser + | + | HTTPS + v +nginx + | + | HTTP :6970 + v +hg_web_server (main.c + Seobeo) + | + | HTTP :4444 + v +hg serve (Zenbu Mercurial repository) +``` + +The C server owns the public routes. It serves the application shell and +assets, translates browser API requests into `hg serve` requests, and streams +Mercurial wire-protocol traffic without routing bundle data through the normal +buffered HTTP response path. + +## Request flows + +### Application shell + +```text +GET /, /directories, or /graph + -> GetReactHome + -> hg-web/src/index.html + -> /page.js + -> src/main.tsx + -> components/app.tsx +``` + +### Repository browser -## Structure +```text +directory-browser.tsx + -> GET /api/repo/list?path=... + -> ApiListDirectory + -> GET hg-serve/file/tip/<path>?style=json +file or README selection + -> GET /api/repo/file?path=... + -> ApiGetFile + -> GET hg-serve/raw-file/tip/<path> + -> highlight.js, markdown_converter WASM, or inline static preview ``` -hg-web/ -├── BUILD # Bazel build configuration -├── deploy.sh # Deployment script -├── main.c # C server handling API routes -└── src/ # Frontend source files + +Images, SVG, video, audio, and PDF files open in the forge preview modal. +Unknown binary files retain a download link. The raw file API supplies explicit +MIME types, `nosniff`, inline disposition for supported previews, and sandboxed +SVG responses. + +### Commit graph + +```text +graph.tsx + -> GET /api/graph/<revision>?graphtop=...&style=json + -> ApiGetGraph + -> GET hg-serve/graph/<revision>?... + -> React rows + custom pencil/panda canvas + +graph row selection + -> /changeset/<revision> + -> GET /api/changeset/<revision> + -> ApiGetChangeset + -> GET hg-serve/json-rev/<revision> + -> changeset metadata + rendered diff +``` + +### Mercurial clone, pull, and push + +```text +Mercurial client + -> GET or POST /repo + -> StreamHgWireProtocol + -> hg serve /?cmd=... + -> binary-safe streamed response ``` -## Building +## Route map + +| Method | Route | Owner | Purpose | +| --- | --- | --- | --- | +| `GET` | `/` | `GetReactHome` | Application shell | +| `GET` | `/directories` | `GetReactHome` | Legacy application-shell route | +| `GET` | `/directory` | `GetReactHome` | Repository browser application route | +| `GET` | `/graph` | `GetReactHome` | Commit graph application route | +| `GET` | `/changeset/:changeset_id` | `GetReactHome` | Changeset application route | +| `GET` | `/api/repo/list` | `ApiListDirectory` | Directory listing JSON | +| `GET` | `/api/repo/file` | `ApiGetFile` | Raw tracked file | +| `GET` | `/api/repo/readme` | `ApiGetReadme` | Directory README content | +| `GET` | `/api/graph/:graph_id` | `ApiGetGraph` | Mercurial graph JSON | +| `GET` | `/api/changeset/:changeset_id` | `ApiGetChangeset` | Changeset metadata and diff JSON | +| `GET`, `POST` | `/repo` | `StreamHgWireProtocol` | Mercurial wire protocol | + +## Frontend ownership + +| File | Responsibility | +| --- | --- | +| `src/components/app.tsx` | Client-side routes, landing page, tabs, and history | +| `src/components/directory-browser.tsx` | Breadcrumbs, listings, prefetch, file modals, README rendering | +| `src/components/graph.tsx` | Graph fetching, pagination, canvas edges, panda nodes | +| `src/components/theme.tsx` | Stored light/dark preference and system-theme integration | +| `src/components/header.tsx` | Forge identity and theme control | +| `src/components/footer.tsx` | Shared footer | +| `src/index.css`, `src/base.css` | Custom visual language and layout | + +Keep the custom assets and visual language in this package. New forge screens +should reuse the existing CSS variables, typography, textures, and components +instead of introducing a generic design system. + +## Build and local run + +Build the server and deployable bundle from the repository root: ```bash -bazel build //hg-web:hg_web +bazel build //hg-web:hg_web_server +bazel build //hg-web:hg_web_server_bundle +bazel test //hg-web:tests +bazel test //markdown_converter/tests:markdown_to_html_test +bazel test //seobeo/tests:all +``` + +`//hg-web:tests` creates a temporary two-commit Mercurial repository and runs +the application in pinned Chromium through Playwright. It covers shell routes, +files and README rendering, graph and changeset navigation, browser history, +console and page errors, API validation, Mercurial wire protocol, Markdown +script escaping, static assets, and backend outages. The test invokes the +workspace Mercurial CLI with an isolated configuration and home directory. + +Build and run the complete local stack with one Bazel command: + +```bash +bazel run //hg-web:dev +``` + +This starts `hg serve` on `127.0.0.1:4444` and `hg_web_server` on port `6970`. +Stopping the Bazel target stops both child processes. + +The bundle contains `hg_web_server` and `hg-web/src/`, including generated +`page.js`, markdown WASM, highlight.js, styles, and image assets. + +## Deployment contract + +The expected production layout is: + +```text +nginx + -> hg_web_server.service + -> /opt/hg_web_server_bundle_active/hg_web_server + -> working directory: /opt/hg_web_server_bundle_active + -> hg serve service on 127.0.0.1:4444 ``` -## API Endpoints +`deploy.sh` builds an optimized bundle into a revisioned release directory, +atomically repoints `/opt/hg_web_server_bundle_active`, restarts +`hg_web_server.service`, and checks `http://127.0.0.1:6970/`. A failed restart +or health check restores the previous release and restarts it. The service, +release root, active path, health URL, user, and group can be overridden with +environment variables. + +## Publishing + +Push committed Mercurial changes to the configured server: + +```bash +hg push +``` + +Then update and deploy on the server: + +```bash +ssh -t [email protected] \ + 'cd ~/zenbu && hg update default && bazel run //hg-web:deploy' +``` + +The SSH key may prompt for its passphrase. `//hg-web:deploy` builds the release +bundle through Bazel before running the atomic promotion, health check, and +rollback workflow. + +## Forge capability tree -The C server (`main.c`) provides: +```text +Zenbu Forge +├── Repository +│ ├── File and directory browsing available +│ ├── Syntax highlighting available +│ ├── README rendering available +│ ├── Commit graph available +│ ├── Changeset detail and diff available +│ ├── Branches, bookmarks, and tags planned +│ ├── File history and blame planned +│ └── Search planned +├── Collaboration +│ ├── Authentication and authorization planned +│ ├── Changeset review planned +│ ├── Issues planned +│ └── Releases and artifacts planned +└── Automation + ├── Mercurial incoming/changegroup hook planned + ├── Durable SQLite job queue planned + ├── Isolated Bazel runner planned + ├── Live logs and job status API planned + ├── Forge status and log screens planned + ├── Artifact retention planned + └── Atomic deploy, health check, rollback available +``` + +## Automation data flow + +The Actions-like subsystem should extend the existing server rather than +replace it: -- `GET /api/repo/list?path=` - List directory contents -- `GET /api/repo/file?path=` - Fetch file contents +```text +hg push + -> Mercurial hook records repository + revision + -> small enqueue command writes a SQLite job + -> runner service claims one queued job + -> isolated shared checkout updates to the exact revision + -> Bazel build and test steps stream logs + -> job result and artifacts are recorded + -> hg-web status API exposes the result + -> custom React screens render runs, steps, logs, and artifacts + -> successful protected jobs may call atomic deployment +``` + +Start with one runner on the same host. Keep the hook fast, never execute build +steps inside the Mercurial request, and make the queue durable before adding +parallel or remote runners. + +## Safe extension order + +1. Add branches, bookmarks, tags, file history, blame, and search. +2. Add the hook, SQLite queue, single runner, and retained logs. +3. Add run/status/log pages using the existing custom UI. +4. Add authentication and authorization before accepting public pushes or + user-defined automation. + +## Invariants + +- Keep public application code on Seobeo APIs. +- Decode and validate every path before forwarding it to Mercurial. +- Keep `/repo` binary-safe; bundle data can contain null bytes. +- Do not buffer large wire-protocol responses through the regular HTTP client. +- Keep backend route names synchronized with frontend fetch and navigation code. +- Preserve accurate status codes, content lengths, and content types. +- Keep secrets in service-owned environment files, not in source or job logs.
--- a/hg-web/deploy.sh Sat Feb 28 21:04:43 2026 -0800 +++ b/hg-web/deploy.sh Sun Aug 02 16:44:06 2026 -0700 @@ -1,15 +1,110 @@ -#!/bin/bash -# sudo groupadd zenbu_team -- already added -# sudo useradd -r -s /usr/sbin/nologin -G zenbu_team hg_web_server -bazel build -c opt //hg-web:hg_web_server_bundle +#!/usr/bin/env bash +set -Eeuo pipefail + +SERVICE_NAME="${SERVICE_NAME:-hg_web_server.service}" +RELEASE_ROOT="${RELEASE_ROOT:-/opt/hg_web_server_releases}" +ACTIVE_PATH="${ACTIVE_PATH:-/opt/hg_web_server_bundle_active}" +HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:6970/}" +SERVICE_USER="${SERVICE_USER:-hg_web_server}" +SERVICE_GROUP="${SERVICE_GROUP:-zenbu_team}" + +workspace="${BUILD_WORKSPACE_DIRECTORY:-$(hg root)}" +cd "$workspace" + +if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then + if [[ -n "${RUNFILES_DIR:-}" ]]; then + source "$RUNFILES_DIR/bazel_tools/tools/bash/runfiles/runfiles.bash" + elif [[ -n "${RUNFILES_MANIFEST_FILE:-}" ]]; then + runfiles_library="$( + grep -sm1 '^bazel_tools/tools/bash/runfiles/runfiles.bash ' \ + "$RUNFILES_MANIFEST_FILE" | cut -d' ' -f2- + )" + source "$runfiles_library" + elif [[ -d "$0.runfiles" ]]; then + RUNFILES_DIR="$0.runfiles" + export RUNFILES_DIR + source "$RUNFILES_DIR/bazel_tools/tools/bash/runfiles/runfiles.bash" + elif [[ -f "$0.runfiles_manifest" ]]; then + RUNFILES_MANIFEST_FILE="$0.runfiles_manifest" + export RUNFILES_MANIFEST_FILE + runfiles_library="$( + grep -sm1 '^bazel_tools/tools/bash/runfiles/runfiles.bash ' \ + "$RUNFILES_MANIFEST_FILE" | cut -d' ' -f2- + )" + source "$runfiles_library" + else + echo "Bazel runfiles are unavailable." >&2 + exit 1 + fi + bundle_dir="$(rlocation _main/hg-web/hg_web_server_bundle)" +else + bazel build -c opt //hg-web:hg_web_server_bundle + bundle_dir="bazel-bin/hg-web/hg_web_server_bundle" +fi + +revision="$(hg log -r . -T '{node|short}')" +release_name="${revision}-$(date -u +%Y%m%dT%H%M%SZ)" +release_dir="${RELEASE_ROOT}/${release_name}" +staging_dir="${RELEASE_ROOT}/.${release_name}.tmp" +next_link="${ACTIVE_PATH}.next" +previous_release="" +promoted=0 -# Create -sudo cp -a bazel-bin/hg-web/hg_web_server_bundle /opt/hg_web_server_bundle_new -sudo chown -R hg_web_server:zenbu_team /opt/hg_web_server_bundle_new +health_check() { + for _ in $(seq 1 20); do + if curl --fail --silent --max-time 3 "$HEALTH_URL" >/dev/null; then + return 0 + fi + sleep 1 + done + echo "Health check failed: $HEALTH_URL" >&2 + return 1 +} + +point_active_at() { + local target="$1" + sudo rm -f "$next_link" + sudo ln -s "$target" "$next_link" + sudo mv -Tf "$next_link" "$ACTIVE_PATH" +} -# Swap -sudo rm -rf /opt/hg_web_server_bundle_active -sudo mv /opt/hg_web_server_bundle_new /opt/hg_web_server_bundle_active +rollback() { + trap - ERR + if [[ "$promoted" -eq 1 && -n "$previous_release" && -d "$previous_release" ]]; then + echo "Deployment failed; rolling back to $previous_release" >&2 + point_active_at "$previous_release" + sudo systemctl restart "$SERVICE_NAME" + health_check || echo "Rollback completed, but the health check still fails." >&2 + else + echo "Deployment failed and no previous release is available for rollback." >&2 + fi + sudo rm -rf "$staging_dir" + exit 1 +} +trap rollback ERR -sudo systemctl restart hg_web_server.service -echo "Deployment complete!" +sudo install -d -o root -g "$SERVICE_GROUP" -m 0755 "$RELEASE_ROOT" +sudo rm -rf "$staging_dir" +sudo install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$staging_dir" +sudo cp -a "${bundle_dir}/." "$staging_dir/" +sudo chown -R "$SERVICE_USER:$SERVICE_GROUP" "$staging_dir" + +sudo test -x "$staging_dir/hg_web_server" +sudo test -f "$staging_dir/hg-web/src/index.html" +sudo test -f "$staging_dir/hg-web/src/page.js" +sudo mv "$staging_dir" "$release_dir" + +if [[ -L "$ACTIVE_PATH" ]]; then + previous_release="$(readlink -f "$ACTIVE_PATH")" +elif [[ -d "$ACTIVE_PATH" ]]; then + previous_release="${RELEASE_ROOT}/legacy-$(date -u +%Y%m%dT%H%M%SZ)" + sudo mv "$ACTIVE_PATH" "$previous_release" +fi + +point_active_at "$release_dir" +promoted=1 +sudo systemctl restart "$SERVICE_NAME" +health_check + +trap - ERR +echo "Deployment complete: $release_dir"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/dev.sh Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -n "${RUNFILES_DIR:-}" ]]; then + source "$RUNFILES_DIR/bazel_tools/tools/bash/runfiles/runfiles.bash" +elif [[ -n "${RUNFILES_MANIFEST_FILE:-}" ]]; then + runfiles_library="$( + grep -sm1 '^bazel_tools/tools/bash/runfiles/runfiles.bash ' \ + "$RUNFILES_MANIFEST_FILE" | cut -d' ' -f2- + )" + source "$runfiles_library" +elif [[ -d "$0.runfiles" ]]; then + RUNFILES_DIR="$0.runfiles" + export RUNFILES_DIR + source "$RUNFILES_DIR/bazel_tools/tools/bash/runfiles/runfiles.bash" +elif [[ -f "$0.runfiles_manifest" ]]; then + RUNFILES_MANIFEST_FILE="$0.runfiles_manifest" + export RUNFILES_MANIFEST_FILE + runfiles_library="$( + grep -sm1 '^bazel_tools/tools/bash/runfiles/runfiles.bash ' \ + "$RUNFILES_MANIFEST_FILE" | cut -d' ' -f2- + )" + source "$runfiles_library" +else + echo "Bazel runfiles are unavailable." >&2 + exit 1 +fi + +workspace="${BUILD_WORKSPACE_DIRECTORY:-}" +if [[ -z "$workspace" || ! -d "$workspace/.hg" ]]; then + echo "Run this target from the Zenbu workspace." >&2 + exit 1 +fi + +server="$(rlocation _main/hg-web/hg_web_server)" +runfiles_workspace="$(dirname "$(dirname "$server")")" +hg_pid="" +server_pid="" + +cleanup() { + trap - EXIT INT TERM + for pid in "$server_pid" "$hg_pid"; do + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + done + for pid in "$server_pid" "$hg_pid"; do + if [[ -n "$pid" ]]; then + wait "$pid" 2>/dev/null || true + fi + done +} + +trap cleanup EXIT +trap 'exit 130' INT TERM + +hg --repository "$workspace" serve \ + --address 127.0.0.1 \ + --port 4444 \ + --accesslog - \ + --errorlog - & +hg_pid=$! + +( + cd "$runfiles_workspace" + exec "$server" +) & +server_pid=$! + +echo "hg-web: http://127.0.0.1:6970" +echo "Mercurial wire endpoint: http://127.0.0.1:6970/repo" +echo "Press Ctrl-C to stop both servers." + +set +e +wait -n "$hg_pid" "$server_pid" +status=$? +set -e +exit "$status"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/BUILD Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,31 @@ +load("@aspect_rules_js//js:defs.bzl", "js_test") +load("@hg_web_npm//:defs.bzl", "npm_link_all_packages") + +exports_files([ + "package-lock.json", + "package.json", + "pnpm-lock.yaml", +]) + +npm_link_all_packages(name = "node_modules") + +js_test( + name = "app_e2e_test", + entry_point = "app_e2e_test.js", + data = [ + ":node_modules/playwright-core", + "//hg-web:hg_web_server", + "@playwright_chromium_linux//:chromium", + "@playwright_chromium_linux//:chrome", + ], + env = { + "CHROMIUM_PATH": "$(rootpath @playwright_chromium_linux//:chrome)", + }, + no_copy_to_bin = ["@playwright_chromium_linux//:chromium"], + size = "large", + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + ], + timeout = "long", +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/app_e2e_test.js Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,417 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); +const { chromium } = require('playwright-core'); + +const BASE_URL = 'http://127.0.0.1:6970'; +const RUNFILES = process.env.JS_BINARY__RUNFILES; +const WORKSPACE = process.env.JS_BINARY__WORKSPACE; + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: 'utf8', + ...options, + }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(' ')} failed\n${result.stdout || ''}${result.stderr || ''}`, + ); + } + return result.stdout.trim(); +} + +function mercurialEnvironment(home) { + return { + ...process.env, + HGPLAIN: '1', + HGRCPATH: '', + HOME: home, + }; +} + +function stopProcess(child) { + if (!child || child.exitCode !== null) return Promise.resolve(); + child.kill('SIGTERM'); + return new Promise(resolve => { + const timer = setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }, 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function waitForHttp(url, child, logs) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Server exited early with ${child.exitCode}\n${logs.join('')}`); + } + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // Keep waiting for startup. + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${url}\n${logs.join('')}`); +} + +function createFixtureRepository(root) { + const options = { env: mercurialEnvironment(root) }; + run('hg', ['init', root], options); + fs.mkdirSync(path.join(root, 'docs'), { recursive: true }); + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'README.md'), + '# Fixture Repository\n\n<script>window.__hgWebXss = true</script>\n', + ); + fs.writeFileSync(path.join(root, 'docs', 'README.md'), '# Documentation\n\nNested README.\n'); + fs.writeFileSync(path.join(root, 'src', 'main.c'), 'int main(void) { return 0; }\n'); + fs.writeFileSync( + path.join(root, 'BUILD'), + 'cc_library(\n name = "fixture",\n srcs = ["src/main.c"],\n)\n', + ); + fs.writeFileSync( + path.join(root, 'pixel.png'), + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nWQAAAAASUVORK5CYII=', + 'base64', + ), + ); + fs.writeFileSync(path.join(root, 'movie.mp4'), Buffer.from('00000018667479706d703432', 'hex')); + fs.writeFileSync(path.join(root, 'sound.mp3'), Buffer.from('ID3')); + fs.writeFileSync(path.join(root, 'document.pdf'), Buffer.from('%PDF-1.4\n%%EOF\n')); + fs.writeFileSync(path.join(root, 'archive.bin'), Buffer.from([0, 1, 2, 3])); + run('hg', ['--repository', root, 'add'], options); + run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Initial fixture'], options); + const firstNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options); + + fs.writeFileSync( + path.join(root, 'README.md'), + '# Updated Fixture Repository\n\n<script>window.__hgWebXss = true</script>\n\nSecond revision.\n', + ); + fs.writeFileSync(path.join(root, 'new-file.txt'), 'new file\n'); + run('hg', ['add', 'new-file.txt'], { ...options, cwd: root }); + run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Update fixture'], options); + const tipNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options); + return { firstNode, tipNode }; +} + +async function assertJson(pathname, status = 200) { + const response = await fetch(`${BASE_URL}${pathname}`); + const body = await response.text(); + assert.equal(response.status, status, `${pathname} status: ${body}`); + return JSON.parse(body); +} + +async function assertPageHasNoBrowserErrors(browser, pathname, assertion) { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(`pageerror: ${error.stack || error.message}`)); + page.on('console', message => { + if (message.type() === 'error') errors.push(`console: ${message.text()}`); + }); + page.on('requestfailed', request => { + errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); + }); + page.on('response', response => { + if (response.status() >= 400) { + errors.push(`response: ${response.status()} ${response.url()}`); + } + }); + + const response = await page.goto(`${BASE_URL}${pathname}`, { waitUntil: 'networkidle' }); + assert.equal(response.status(), 200, `${pathname} document status`); + await assertion(page); + if (errors.length > 0) { + throw new Error(`${pathname} browser errors\n${errors.join('\n')}`); + } + await page.close(); +} + +async function main() { + assert.ok(RUNFILES, 'rules_js runfiles path is required'); + assert.ok(WORKSPACE, 'rules_js workspace name is required'); + + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hg-web-e2e-')); + const appLogs = []; + const hgLogs = []; + let hgServer; + let appServer; + let browser; + + try { + const { firstNode, tipNode } = createFixtureRepository(fixtureRoot); + const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); + const serverBinary = path.join(runfilesWorkspace, 'hg-web', 'hg_web_server'); + const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); + + assert.ok(fs.existsSync(serverBinary), `missing server binary: ${serverBinary}`); + assert.ok(fs.existsSync(chromiumPath), `missing Chromium binary: ${chromiumPath}`); + + hgServer = spawn( + 'hg', + [ + '--repository', fixtureRoot, + 'serve', + '--address', '127.0.0.1', + '--port', '4444', + '--accesslog', '-', + '--errorlog', '-', + ], + { + env: mercurialEnvironment(fixtureRoot), + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + hgServer.stdout.on('data', chunk => hgLogs.push(chunk.toString())); + hgServer.stderr.on('data', chunk => hgLogs.push(chunk.toString())); + + appServer = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + stdio: ['ignore', 'pipe', 'pipe'], + }); + appServer.stdout.on('data', chunk => appLogs.push(chunk.toString())); + appServer.stderr.on('data', chunk => appLogs.push(chunk.toString())); + + await waitForHttp(`${BASE_URL}/`, appServer, appLogs); + if (hgServer.exitCode !== null) { + throw new Error(`Mercurial server exited early with ${hgServer.exitCode}\n${hgLogs.join('')}`); + } + await waitForHttp(`${BASE_URL}/api/repo/list`, hgServer, hgLogs); + + for (const pathname of ['/', '/directory', '/directory?path=docs', '/graph', `/changeset/${tipNode}`]) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 200, `${pathname} shell route`); + assert.match(await response.text(), /<title>Zenbu Repository<\/title>/); + } + for (const pathname of ['/page.js', '/index.css', '/base.css', '/pencil_lines.png', '/panda.png']) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 200, `${pathname} static asset`); + assert.ok((await response.arrayBuffer()).byteLength > 0, `${pathname} is non-empty`); + } + for (const pathname of ['/hg-web-background.jpg', '/pencil_texture.png']) { + assert.equal((await fetch(`${BASE_URL}${pathname}`)).status, 404); + } + + const rootList = await assertJson('/api/repo/list'); + assert.ok(rootList.directories.some(entry => entry.basename === 'docs')); + assert.ok(rootList.files.some(entry => entry.basename === 'README.md')); + + const nestedList = await assertJson('/api/repo/list?path=docs'); + assert.ok(nestedList.files.some(entry => entry.basename === 'README.md')); + + const fileResponse = await fetch(`${BASE_URL}/api/repo/file?path=src%2Fmain.c`); + assert.equal(fileResponse.status, 200); + assert.match(await fileResponse.text(), /int main/); + + for (const [filename, contentType, disposition] of [ + ['pixel.png', 'image/png', 'inline'], + ['movie.mp4', 'video/mp4', 'inline'], + ['sound.mp3', 'audio/mpeg', 'inline'], + ['document.pdf', 'application/pdf', 'inline'], + ['archive.bin', 'application/octet-stream', 'attachment'], + ]) { + const response = await fetch( + `${BASE_URL}/api/repo/file?path=${encodeURIComponent(filename)}`, + ); + assert.equal(response.status, 200, `${filename} response`); + assert.match(response.headers.get('content-type') || '', new RegExp(`^${contentType}`)); + assert.equal(response.headers.get('content-disposition'), disposition); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); + assert.ok((await response.arrayBuffer()).byteLength > 0); + } + const binaryResponse = await fetch(`${BASE_URL}/api/repo/file?path=archive.bin`); + assert.deepEqual( + Buffer.from(await binaryResponse.arrayBuffer()), + Buffer.from([0, 1, 2, 3]), + ); + + const readmeResponse = await fetch(`${BASE_URL}/api/repo/readme?path=docs`); + assert.equal(readmeResponse.status, 200); + assert.match(await readmeResponse.text(), /Nested README/); + assert.equal((await fetch(`${BASE_URL}/api/repo/readme?path=src`)).status, 204); + + const graph = await assertJson('/api/graph/tip?style=json'); + assert.equal(graph.node, tipNode); + assert.ok(graph.changesets.length >= 2); + + const changeset = await assertJson(`/api/changeset/${tipNode}`); + assert.equal(changeset.node, tipNode); + assert.equal(changeset.desc, 'Update fixture'); + assert.ok(changeset.files.some(entry => entry.file === 'new-file.txt' && entry.status === 'added')); + assert.ok(changeset.diff.length > 0); + + for (const pathname of [ + '/api/repo/list?path=..%2Fetc', + '/api/repo/file?path=..%2FREADME.md', + '/api/graph/not-a-node?style=json', + '/api/changeset/not-a-node', + ]) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 400, `${pathname} rejects invalid input`); + } + assert.equal((await fetch(`${BASE_URL}/missing-route`)).status, 404); + + const identify = run( + 'hg', + ['identify', `${BASE_URL}/repo`], + { env: mercurialEnvironment(fixtureRoot) }, + ); + assert.match(identify, new RegExp(`^${tipNode.slice(0, 12)}`)); + + browser = await chromium.launch({ + executablePath: chromiumPath, + headless: true, + args: ['--no-sandbox'], + }); + + await assertPageHasNoBrowserErrors(browser, '/', async page => { + await page.getByRole('heading', { name: 'Zenbu Repository' }).waitFor(); + await page.getByText('Recent Commits').waitFor(); + await page.getByText('Repository Files').waitFor(); + assert.equal(await page.evaluate(() => window.__hgWebXss), undefined); + assert.equal( + await page.locator('.graph-container').evaluate( + element => getComputedStyle(element).backgroundImage, + ), + 'none', + ); + await page.locator('.theme-toggle').click(); + }); + + await assertPageHasNoBrowserErrors(browser, '/directory', async page => { + await page.getByText('Repository Files').waitFor(); + await page.getByRole('link', { name: 'README.md' }).first().click(); + await page.getByText('Updated Fixture Repository').waitFor(); + assert.equal(await page.evaluate(() => window.__hgWebXss), undefined); + await page.keyboard.press('Escape'); + + let delayedReadmeRequested = false; + let markReadmeRequested; + const readmeRequested = new Promise(resolve => { + markReadmeRequested = resolve; + }); + await page.route(/\/api\/repo\/readme\?path=/, async route => { + delayedReadmeRequested = true; + markReadmeRequested(); + await new Promise(resolve => setTimeout(resolve, 500)); + await route.continue(); + }); + await page.getByRole('link', { name: 'docs' }).click(); + await Promise.race([ + readmeRequested, + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timed out waiting for delayed README request')), 5000); + }), + ]); + await page.getByRole('link', { name: 'root' }).click(); + await page.getByText('Updated Fixture Repository').waitFor(); + await page.waitForTimeout(600); + assert.equal(await page.getByText('Documentation').count(), 0); + assert.equal(delayedReadmeRequested, true); + + await page.getByRole('link', { name: 'pixel.png' }).click(); + const image = page.locator('.static-file-image'); + await image.waitFor(); + await image.evaluate(element => { + const imageElement = element; + if (imageElement.complete) return; + return new Promise((resolve, reject) => { + imageElement.addEventListener('load', resolve, { once: true }); + imageElement.addEventListener('error', reject, { once: true }); + }); + }); + assert.equal(await image.evaluate(element => element.naturalWidth), 1); + await page.getByRole('dialog', { name: 'Preview pixel.png' }).waitFor(); + await page.keyboard.press('Escape'); + + await page.getByRole('link', { name: 'BUILD' }).click(); + const buildCode = page.locator('code.language-python'); + await buildCode.waitFor(); + await page.getByText('cc_library').waitFor(); + assert.match(await buildCode.textContent(), /name = "fixture"/); + await page.keyboard.press('Escape'); + + await page.getByRole('link', { name: 'src' }).click(); + await page.getByRole('link', { name: 'main.c' }).click(); + await page.getByText('int main(void)').waitFor(); + await page.keyboard.press('Escape'); + }); + + await assertPageHasNoBrowserErrors(browser, '/directory?path=docs', async page => { + await page.getByText('Documentation').waitFor(); + await page.getByRole('link', { name: 'README.md' }).click(); + await page.getByText('Nested README.').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, '/graph', async page => { + await page.getByText('Commit Graph').waitFor(); + await page.waitForFunction(() => new URL(window.location.href).searchParams.has('tip')); + const graphUrl = page.url(); + const rows = page.locator('.graph-row'); + await rows.first().click(); + await page.waitForURL(/\/changeset\/[0-9a-f]+$/); + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.waitForFunction(expected => window.location.href === expected, graphUrl); + await page.getByText('Commit Graph').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, `/changeset/${tipNode}`, async page => { + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.locator('.changeset-files code').filter({ hasText: 'new-file.txt' }).waitFor(); + await page.getByText('added', { exact: true }).waitFor(); + await page.getByRole('region', { name: 'Changeset diff' }).waitFor(); + await page.locator('.diff-column-headings').getByText('Before').first().waitFor(); + await page.locator('.diff-column-headings').getByText('After').first().waitFor(); + await page.locator('.diff-left.diff-remove').filter({ hasText: '# Fixture Repository' }).waitFor(); + await page.locator('.diff-right.diff-add').filter({ hasText: '# Updated Fixture Repository' }).waitFor(); + assert.equal( + await page.locator('.diff-left.diff-context').filter({ hasText: '<script>' }).first().textContent(), + '<script>window.__hgWebXss = true</script>', + ); + assert.equal( + await page.locator('.changeset-paper').evaluate( + element => getComputedStyle(element).backgroundImage, + ), + 'none', + ); + await page.getByRole('button', { name: firstNode.slice(0, 12) }).click(); + await page.waitForFunction( + expectedPath => window.location.pathname === expectedPath, + `/changeset/${firstNode}`, + ); + await page.getByRole('heading', { name: 'Initial fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByText('Commit Graph').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, '/changeset/not-a-node', async page => { + await page.getByText('Recent Commits').waitFor(); + }); + + await stopProcess(hgServer); + const unavailable = await fetch(`${BASE_URL}/api/repo/list`); + assert.equal(unavailable.status, 502); + assert.equal((await fetch(`${BASE_URL}/`)).status, 200); + } finally { + if (browser) await browser.close(); + await stopProcess(appServer); + await stopProcess(hgServer); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } +} + +main().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +});
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/package-lock.json Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,24 @@ +{ + "name": "e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "playwright-core": "1.48.2" + } + }, + "node_modules/playwright-core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz", + "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/package.json Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,6 @@ +{ + "private": true, + "dependencies": { + "playwright-core": "1.48.2" + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/pnpm-lock.yaml Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,18 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + playwright-core: + specifier: 1.48.2 + version: 1.48.2 + +packages: + + /[email protected]: + resolution: {integrity: sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==} + engines: {node: '>=18'} + hasBin: true + dev: false
--- a/hg-web/main.c Sat Feb 28 21:04:43 2026 -0800 +++ b/hg-web/main.c Sun Aug 02 16:44:06 2026 -0700 @@ -1,450 +1,780 @@ #include "seobeo/seobeo.h" #include "dowa/dowa.h" + +#include <ctype.h> +#include <errno.h> #include <stdio.h> #include <stdlib.h> #include <string.h> -#include <ctype.h> +#include <strings.h> +#include <time.h> #include <unistd.h> -#include <sys/socket.h> -#include <netinet/in.h> -#include <arpa/inet.h> -#include <netdb.h> #define HG_SERVE_HOST "127.0.0.1" #define HG_SERVE_PORT "4444" - -#define MAX_PATH 4096 +#define HG_API_TIMEOUT_MS 15000 +#define HG_STREAM_IDLE_TIMEOUT_MS 60000 +#define MAX_PATH_LENGTH 4096 +#define MAX_WIRE_QUERY_LENGTH 8192 +#define MAX_WIRE_HEADER_LENGTH 8192 -static char* sanitize_path(const char *input_path, Dowa_Arena *arena) +static const char *map_value_case_insensitive(Seobeo_Request_Entry *map, const char *key) { - if (!input_path || strlen(input_path) == 0) + if (!map || !key) + return NULL; + + for (size_t i = 0; i < Dowa_Array_Length(map); i++) { - char *empty = Dowa_Arena_Allocate(arena, 1); - empty[0] = '\0'; - return empty; + if (map[i].key && strcasecmp(map[i].key, key) == 0) + return map[i].value; } + return NULL; +} + +static char *arena_string(Dowa_Arena *arena, const char *value) +{ + size_t length = strlen(value); + char *copy = Dowa_Arena_Allocate(arena, length + 1); + memcpy(copy, value, length + 1); + return copy; +} - size_t len = strlen(input_path); - char *result = Dowa_Arena_Allocate(arena, len + 1); - size_t j = 0; +static Seobeo_Request_Entry *text_response( + Dowa_Arena *arena, + const char *status, + const char *content_type, + const char *body) +{ + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", arena_string(arena, status), arena); + Dowa_HashMap_Push_Arena( + response, "content-type", arena_string(arena, content_type), arena); + Dowa_HashMap_Push_Arena(response, "body", arena_string(arena, body), arena); + return response; +} - for (size_t i = 0; i < len; i++) +static boolean decode_url_component( + const char *encoded, + Dowa_Arena *arena, + char **decoded_out, + size_t *decoded_length_out) +{ + if (!encoded || !decoded_out) + return FALSE; + + size_t encoded_length = strlen(encoded); + if (encoded_length >= MAX_PATH_LENGTH) + return FALSE; + + char *decoded = Dowa_Arena_Allocate(arena, encoded_length + 1); + size_t output_length = 0; + for (size_t i = 0; i < encoded_length; i++) { - if (input_path[i] == '.' && (i == 0 || input_path[i-1] == '/')) + unsigned char value = (unsigned char)encoded[i]; + if (encoded[i] == '%') { - if (i + 1 < len && input_path[i+1] == '.') - { - // Skip ".." - i++; - continue; - } - // Skip "." - continue; + if (i + 2 >= encoded_length || + !isxdigit((unsigned char)encoded[i + 1]) || + !isxdigit((unsigned char)encoded[i + 2])) + return FALSE; + + char hex[3] = {encoded[i + 1], encoded[i + 2], '\0'}; + value = (unsigned char)strtoul(hex, NULL, 16); + i += 2; + if (value == '\0') + return FALSE; } - result[j++] = input_path[i]; + decoded[output_length++] = (char)value; } - result[j] = '\0'; + decoded[output_length] = '\0'; - // Remove leading/trailing slashes - while (result[0] == '/') - memmove(result, result + 1, strlen(result)); - while (j > 0 && result[j-1] == '/') - result[--j] = '\0'; - - return result; + *decoded_out = decoded; + if (decoded_length_out) + *decoded_length_out = output_length; + return TRUE; } -Seobeo_Client_Response *hg_proxy_request( - const char *method, - const char *path, - const char *req_body, - const char *hg_custom) +static boolean normalize_repository_path( + const char *encoded_path, + Dowa_Arena *arena, + char **normalized_out) { - char full_path[MAX_PATH]; - snprintf(full_path, MAX_PATH, "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path); - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy PATH %s\n", full_path); - Seobeo_Client_Request *p_req = Seobeo_Client_Request_Create(full_path); - Seobeo_Client_Request_Set_Method(p_req, method); - Seobeo_Client_Request_Add_Header_Array(p_req, "User-Agent: Seobeo/1.0"); - Seobeo_Client_Request_Add_Header_Array(p_req, "Accept: application/json"); + char *decoded = NULL; + size_t decoded_length = 0; + if (!decode_url_component(encoded_path ? encoded_path : "", arena, &decoded, &decoded_length)) + return FALSE; - if (hg_custom && hg_custom[0] != '\0') + size_t start = 0; + size_t end = decoded_length; + if (start < end && decoded[start] == '/') { - char buffer[1024]; - snprintf(buffer, 1024, "x-hgarg-1: %s", hg_custom); - Seobeo_Client_Request_Add_Header_Array(p_req, buffer); - Seobeo_Log(SEOBEO_DEBUG, "HG CUSTOM %s\n", buffer); + start++; + if (start < end && decoded[start] == '/') + return FALSE; + } + if (end > start && decoded[end - 1] == '/') + { + if (end - 1 > start && decoded[end - 2] == '/') + return FALSE; + end--; } - if (req_body) - Seobeo_Client_Request_Set_Body(p_req, req_body, strlen(req_body)); - Seobeo_Client_Response *p_resp = Seobeo_Client_Request_Execute(p_req); - Seobeo_Client_Request_Destroy(p_req); - return p_resp; + size_t segment_start = start; + for (size_t i = start; i <= end; i++) + { + boolean at_end = i == end; + unsigned char c = at_end ? '/' : (unsigned char)decoded[i]; + if (!at_end && (iscntrl(c) || c == '\\' || c == '?' || c == '#')) + return FALSE; + + if (c == '/') + { + size_t segment_length = i - segment_start; + if (segment_length == 0 && !at_end) + return FALSE; + if ((segment_length == 1 && decoded[segment_start] == '.') || + (segment_length == 2 && decoded[segment_start] == '.' && + decoded[segment_start + 1] == '.')) + return FALSE; + segment_start = i + 1; + } + } + + size_t normalized_length = end - start; + char *normalized = Dowa_Arena_Allocate(arena, normalized_length + 1); + memcpy(normalized, decoded + start, normalized_length); + normalized[normalized_length] = '\0'; + *normalized_out = normalized; + return TRUE; +} + +static boolean validate_revision(const char *revision) +{ + if (!revision || revision[0] == '\0') + return FALSE; + if (strcmp(revision, "tip") == 0) + return TRUE; + + size_t length = strlen(revision); + if (length > 40) + return FALSE; + for (size_t i = 0; i < length; i++) + { + if (!isxdigit((unsigned char)revision[i])) + return FALSE; + } + return TRUE; } -Seobeo_Request_Entry* ApiListDirectory(Seobeo_Request_Entry *req, Dowa_Arena *arena) +static char *encode_repository_path(const char *path, Dowa_Arena *arena) { - Seobeo_Request_Entry *resp = NULL; - - void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path"); - const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : ""; - - char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); - Seobeo_Url_Decode(decoded_path, rel_path); + static const char hex[] = "0123456789ABCDEF"; + size_t length = strlen(path); + char *encoded = Dowa_Arena_Allocate(arena, length * 3 + 1); + size_t output = 0; + for (size_t i = 0; i < length; i++) + { + unsigned char c = (unsigned char)path[i]; + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~' || c == '/') + encoded[output++] = (char)c; + else + { + encoded[output++] = '%'; + encoded[output++] = hex[c >> 4]; + encoded[output++] = hex[c & 0x0F]; + } + } + encoded[output] = '\0'; + return encoded; +} - char *safe_path = sanitize_path(decoded_path, arena); - - Seobeo_Log(SEOBEO_INFO, "ApiListDirectory: safe_path='%s'\n", safe_path); +static boolean safe_header_value(const char *value, size_t maximum_length) +{ + if (!value) + return TRUE; + size_t length = strlen(value); + return length <= maximum_length && + strchr(value, '\r') == NULL && + strchr(value, '\n') == NULL; +} - char hg_path[MAX_PATH]; - if (strlen(safe_path) > 0) - snprintf(hg_path, sizeof(hg_path), "/file/tip/%s?style=json", safe_path); - else - snprintf(hg_path, sizeof(hg_path), "/file/tip/?style=json"); +static boolean extension_is(const char *extension, const char *expected) +{ + return extension && strcasecmp(extension, expected) == 0; +} + +static const char *repository_file_content_type( + const char *path, + boolean *inline_preview, + boolean *sandbox_content) +{ + const char *extension = strrchr(path, '.'); + *inline_preview = TRUE; + *sandbox_content = FALSE; - Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); - - Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); - - if (hg_response->status_code != 200) + if (extension_is(extension, ".png")) return "image/png"; + if (extension_is(extension, ".jpg") || + extension_is(extension, ".jpeg")) return "image/jpeg"; + if (extension_is(extension, ".gif")) return "image/gif"; + if (extension_is(extension, ".webp")) return "image/webp"; + if (extension_is(extension, ".avif")) return "image/avif"; + if (extension_is(extension, ".bmp")) return "image/bmp"; + if (extension_is(extension, ".ico")) return "image/x-icon"; + if (extension_is(extension, ".svg")) { - Seobeo_Log(SEOBEO_DEBUG, "Failed to get directory from hg serve\n"); - Dowa_HashMap_Push_Arena(resp, "status", "502", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to connect to hg serve\"}", arena); - return resp; + *sandbox_content = TRUE; + return "image/svg+xml"; } + if (extension_is(extension, ".mp4") || + extension_is(extension, ".m4v")) return "video/mp4"; + if (extension_is(extension, ".webm")) return "video/webm"; + if (extension_is(extension, ".mov")) return "video/quicktime"; + if (extension_is(extension, ".ogv")) return "video/ogg"; + if (extension_is(extension, ".mp3")) return "audio/mpeg"; + if (extension_is(extension, ".wav")) return "audio/wav"; + if (extension_is(extension, ".ogg") || + extension_is(extension, ".oga")) return "audio/ogg"; + if (extension_is(extension, ".flac")) return "audio/flac"; + if (extension_is(extension, ".m4a")) return "audio/mp4"; + if (extension_is(extension, ".aac")) return "audio/aac"; + if (extension_is(extension, ".pdf")) return "application/pdf"; + if (extension_is(extension, ".wasm")) return "application/wasm"; - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); - - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - return resp; + *inline_preview = FALSE; + if (extension_is(extension, ".md") || + extension_is(extension, ".markdown")) return "text/markdown; charset=utf-8"; + if (extension_is(extension, ".txt") || + extension_is(extension, ".log") || + extension_is(extension, ".c") || + extension_is(extension, ".h") || + extension_is(extension, ".cc") || + extension_is(extension, ".cpp") || + extension_is(extension, ".js") || + extension_is(extension, ".jsx") || + extension_is(extension, ".ts") || + extension_is(extension, ".tsx") || + extension_is(extension, ".css") || + extension_is(extension, ".html") || + extension_is(extension, ".htm") || + extension_is(extension, ".xml") || + extension_is(extension, ".json") || + extension_is(extension, ".yaml") || + extension_is(extension, ".yml") || + extension_is(extension, ".toml") || + extension_is(extension, ".sh") || + extension_is(extension, ".py") || + extension_is(extension, ".rs") || + extension_is(extension, ".go")) + return "text/plain; charset=utf-8"; + return "application/octet-stream"; } -Seobeo_Request_Entry* ApiGetGraph(Seobeo_Request_Entry *req, Dowa_Arena *arena) +static Seobeo_Client_Response *hg_proxy_request( + const char *method, + const char *path, + const char *request_body, + size_t request_body_length, + const char *hg_argument, + const char *accept) { - Seobeo_Request_Entry *resp = NULL; + char url[MAX_PATH_LENGTH]; + int url_length = snprintf( + url, sizeof(url), "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path); + if (url_length < 0 || (size_t)url_length >= sizeof(url)) + return NULL; - void *path_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); - const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : ""; - Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: rel_path='%s'\n", rel_path); - void *graph_id_kv = Dowa_HashMap_Get_Ptr(req, ":graph_id"); - char *graph_id = ((Seobeo_Request_Entry*)graph_id_kv)->value; - Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: graph_id='%s'\n", graph_id); - char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); - Seobeo_Url_Decode(decoded_path, rel_path); - char *safe_path = sanitize_path(decoded_path, arena); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + if (!request) + return NULL; - Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: safe_path='%s'\n", safe_path); + Seobeo_Client_Request_Set_Method(request, method); + Seobeo_Client_Request_Add_Header_Map(request, "User-Agent", "Seobeo/1.0"); + Seobeo_Client_Request_Add_Header_Map( + request, "Accept", accept ? accept : "application/json"); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, HG_API_TIMEOUT_MS); - if (strlen(safe_path) == 0) + if (hg_argument && hg_argument[0] != '\0') { - Dowa_HashMap_Push_Arena(resp, "status", "400", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena); - return resp; + if (!safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH)) + { + Seobeo_Client_Request_Destroy(request); + return NULL; + } + Seobeo_Client_Request_Add_Header_Map(request, "x-hgarg-1", hg_argument); } - char hg_path[MAX_PATH]; - // void *graph_id_kv = Dowa_HashMap_Get_Ptr(req, ":graph_id"); - // char *graph_id = ((Seobeo_Request_Entry*)graph_id_kv)->value; - snprintf(hg_path, sizeof(hg_path), "/graph/%s?%s", graph_id, safe_path); - Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); + if (request_body && request_body_length > 0) + Seobeo_Client_Request_Set_Body(request, request_body, request_body_length); + + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + Seobeo_Client_Request_Destroy(request); + return response; +} - Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); +static Seobeo_Request_Entry *forward_hg_response( + Seobeo_Client_Response *hg_response, + const char *default_content_type, + const char *override_content_type, + Dowa_Arena *arena) +{ + if (!hg_response) + return text_response( + arena, "502", "application/json", "{\"error\":\"Mercurial backend unavailable\"}"); - char status[4]; - snprintf(status, 4, "%i", hg_response->status_code); + const char *upstream_content_type = + map_value_case_insensitive(hg_response->headers, "Content-Type"); + const char *upstream_or_default_content_type = + override_content_type + ? override_content_type + : upstream_content_type ? upstream_content_type : default_content_type; + if (!upstream_or_default_content_type) + upstream_or_default_content_type = "application/octet-stream"; + char *content_type = arena_string(arena, upstream_or_default_content_type); + + char *status = Dowa_Arena_Allocate(arena, 8); + snprintf(status, 8, "%d", hg_response->status_code); - if (!hg_response->body) - { - Dowa_HashMap_Push_Arena(resp, "status", "502", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena); - return resp; - } + size_t body_length = hg_response->body ? hg_response->body_length : 0; + char *body = Dowa_Arena_Allocate(arena, body_length + 1); + if (body_length > 0) + memcpy(body, hg_response->body, body_length); + body[body_length] = '\0'; + char *content_length = Dowa_Arena_Allocate(arena, 32); + snprintf(content_length, 32, "%zu", body_length); + + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", status, arena); + Dowa_HashMap_Push_Arena(response, "content-type", content_type, arena); + Dowa_HashMap_Push_Arena(response, "body", body, arena); + Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena); + Seobeo_Client_Response_Destroy(hg_response); + return response; +} - if (hg_response->status_code != 200) - { - Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: error hg_response: %s\n", hg_response->body); - Dowa_HashMap_Push_Arena(resp, "status", status, arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena); - return resp; - } +Seobeo_Request_Entry *ApiListDirectory(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *encoded_path = map_value_case_insensitive(request, "query_path"); + char *path = NULL; + if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid repository path\"}"); + char *encoded = encode_repository_path(path, arena); + char hg_path[MAX_PATH_LENGTH]; + int length = snprintf( + hg_path, + sizeof(hg_path), + encoded[0] ? "/file/tip/%s?style=json" : "/file/tip/?style=json", + encoded); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Repository path is too long\"}"); + + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), + "application/json", + NULL, + arena); +} - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); +Seobeo_Request_Entry *ApiGetFile(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *encoded_path = map_value_case_insensitive(request, "query_path"); + char *path = NULL; + if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path) || + path[0] == '\0') + return text_response(arena, "400", "text/plain", "A valid file path is required"); + + char *encoded = encode_repository_path(path, arena); + char hg_path[MAX_PATH_LENGTH]; + int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "text/plain", "File path is too long"); - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); + Seobeo_Client_Response *hg_response = + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/octet-stream"); + if (!hg_response) + return forward_hg_response(NULL, "application/json", NULL, arena); - return resp; + boolean inline_preview = FALSE; + boolean sandbox_content = FALSE; + const char *content_type = + repository_file_content_type(path, &inline_preview, &sandbox_content); + Seobeo_Request_Entry *response = forward_hg_response( + hg_response, "application/octet-stream", content_type, arena); + Dowa_HashMap_Push_Arena( + response, + "Content-Disposition", + inline_preview ? "inline" : "attachment", + arena); + Dowa_HashMap_Push_Arena( + response, "X-Content-Type-Options", "nosniff", arena); + if (sandbox_content) + Dowa_HashMap_Push_Arena( + response, "Content-Security-Policy", "sandbox", arena); + return response; } -Seobeo_Request_Entry* ApiGetFile(Seobeo_Request_Entry *req, Dowa_Arena *arena) +Seobeo_Request_Entry *ApiGetReadme(Seobeo_Request_Entry *request, Dowa_Arena *arena) { - Seobeo_Request_Entry *resp = NULL; + const char *encoded_path = map_value_case_insensitive(request, "query_path"); + char *directory = NULL; + if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &directory)) + return text_response(arena, "400", "text/plain", "Invalid repository path"); + + size_t readme_length = strlen(directory) + strlen("/README.md") + 1; + if (readme_length >= MAX_PATH_LENGTH) + return text_response(arena, "400", "text/plain", "README path is too long"); - void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path"); - const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : ""; - char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); - Seobeo_Url_Decode(decoded_path, rel_path); - char *safe_path = sanitize_path(decoded_path, arena); + char *readme_path = Dowa_Arena_Allocate(arena, readme_length); + snprintf( + readme_path, + readme_length, + directory[0] ? "%s/README.md" : "README.md", + directory); + char *encoded = encode_repository_path(readme_path, arena); - Seobeo_Log(SEOBEO_INFO, "ApiGetFile: safe_path='%s'\n", safe_path); + char hg_path[MAX_PATH_LENGTH]; + int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "text/plain", "README path is too long"); - if (strlen(safe_path) == 0) + Seobeo_Client_Response *hg_response = + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "text/markdown"); + if (hg_response && hg_response->status_code == HTTP_NOT_FOUND) { - Dowa_HashMap_Push_Arena(resp, "status", "400", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena); - return resp; + Seobeo_Client_Response_Destroy(hg_response); + return text_response(arena, "204", "text/markdown", ""); } - - char hg_path[MAX_PATH]; - snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", safe_path); - Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); + return forward_hg_response( + hg_response, "text/markdown", "text/markdown; charset=utf-8", arena); +} - Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); - - char status[4]; - snprintf(status, 4, "%i", hg_response->status_code); +Seobeo_Request_Entry *ApiGetGraph(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *graph_id = map_value_case_insensitive(request, ":graph_id"); + if (!validate_revision(graph_id)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph revision\"}"); - if (!hg_response->body) + const char *encoded_graph_top = + map_value_case_insensitive(request, "query_graphtop"); + char *graph_top = NULL; + if (encoded_graph_top) { - Dowa_HashMap_Push_Arena(resp, "status", "502", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena); - return resp; + if (!decode_url_component(encoded_graph_top, arena, &graph_top, NULL) || + !validate_revision(graph_top)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph top revision\"}"); } - if (hg_response->status_code != 200) - { - Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: error hg_response: %s\n", hg_response->body); - Dowa_HashMap_Push_Arena(resp, "status", status, arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena); - return resp; - } - + char hg_path[MAX_PATH_LENGTH]; + int length = graph_top + ? snprintf( + hg_path, + sizeof(hg_path), + "/graph/%s?graphtop=%s&style=json", + graph_id, + graph_top) + : snprintf(hg_path, sizeof(hg_path), "/graph/%s?style=json", graph_id); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Graph request is too long\"}"); - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); - - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - - return resp; + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), + "application/json", + NULL, + arena); } -Seobeo_Request_Entry* ApiGetReadme(Seobeo_Request_Entry *req, Dowa_Arena *arena) { - return ApiGetFile(req, arena); +Seobeo_Request_Entry *ApiGetChangeset(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *changeset_id = map_value_case_insensitive(request, ":changeset_id"); + if (!validate_revision(changeset_id)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid changeset revision\"}"); + + char hg_path[128]; + int length = snprintf(hg_path, sizeof(hg_path), "/json-rev/%s", changeset_id); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Changeset request is too long\"}"); + + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), + "application/json", + NULL, + arena); +} + +static int64_t monotonic_milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static size_t find_http_header_length(const uint8 *buffer, size_t length) +{ + if (!buffer || length < 4) + return 0; + for (size_t i = 0; i + 3 < length; i++) + { + if (buffer[i] == '\r' && buffer[i + 1] == '\n' && + buffer[i + 2] == '\r' && buffer[i + 3] == '\n') + return i + 4; + } + return 0; } -// Streaming handler for hg wire protocol - pipes data directly without buffering -void StreamHgWireProtocol(Seobeo_Handle *p_client, Seobeo_Request_Entry *req, Dowa_Arena *arena) +static void send_proxy_error(Seobeo_Handle *client, int status, const char *message) { - void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method"); - const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET"; - - void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); - const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : ""; - - void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); - const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : ""; - - const char *hg_custom = req[7].value; + const char *reason = status == 504 ? "Gateway Timeout" : "Bad Gateway"; + char response[512]; + int length = snprintf( + response, + sizeof(response), + "HTTP/1.1 %d %s\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: %zu\r\n" + "Connection: close\r\n" + "\r\n" + "%s", + status, + reason, + strlen(message), + message); + if (length > 0 && (size_t)length < sizeof(response)) + { + Seobeo_Handle_Queue(client, (const uint8 *)response, (uint32)length); + Seobeo_Handle_Flush(client); + } +} - Seobeo_Log(SEOBEO_DEBUG, "HG Stream Proxy: method=%s query=%s\n", method, query_string); +static boolean parse_content_length(const char *value, size_t *length_out) +{ + if (!value || !length_out || value[0] == '\0') + return FALSE; + errno = 0; + char *end = NULL; + unsigned long long parsed = strtoull(value, &end, 10); + if (errno != 0 || !end || *end != '\0' || parsed > SIZE_MAX) + return FALSE; + *length_out = (size_t)parsed; + return TRUE; +} - // THINKING: Connect to hg serve - // This kinda blows, but not a good way to handle it since my client API assumes it is all stored in - // buffer and what not. - Seobeo_Handle *p_upstream = Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE); - if (!p_upstream || p_upstream->socket < 0) +void StreamHgWireProtocol( + Seobeo_Handle *client, + Seobeo_Request_Entry *request, + Dowa_Arena *arena) +{ + (void)arena; + const char *method = map_value_case_insensitive(request, "HTTP_Method"); + const char *query = map_value_case_insensitive(request, "QueryString"); + const char *body = map_value_case_insensitive(request, "Body"); + const char *content_length_value = + map_value_case_insensitive(request, "Content-Length"); + const char *content_type = map_value_case_insensitive(request, "Content-Type"); + const char *hg_argument = map_value_case_insensitive(request, "x-hgarg-1"); + + if (!method || !query || + (strcmp(method, "GET") != 0 && strcmp(method, "POST") != 0) || + !safe_header_value(query, MAX_WIRE_QUERY_LENGTH) || + !safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH) || + !safe_header_value(content_type, 256)) { - const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 26\r\n\r\nFailed to connect upstream"; - Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp)); - Seobeo_Handle_Flush(p_client); - if (p_upstream) - Seobeo_Handle_Destroy(p_upstream); + send_proxy_error(client, 502, "Invalid Mercurial proxy request"); return; } - // Create headers - // we only allow x-hgarg-1 and content-length - char request_buf[8192]; - int req_len = snprintf(request_buf, sizeof(request_buf), - "%s /?%s HTTP/1.1\r\n" - "Host: %s:%s\r\n" - "User-Agent: Seobeo/1.0\r\n" - "Connection: close\r\n", - method, query_string, HG_SERVE_HOST, HG_SERVE_PORT); + size_t body_length = 0; + if (content_length_value && + !parse_content_length(content_length_value, &body_length)) + { + send_proxy_error(client, 502, "Invalid Mercurial request length"); + return; + } + if (body_length > 0 && !body) + { + send_proxy_error(client, 502, "Missing Mercurial request body"); + return; + } + if (body_length > UINT32_MAX) + { + send_proxy_error(client, 502, "Mercurial request body is too large"); + return; + } - if (hg_custom && hg_custom[0] != '\0') - req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "x-hgarg-1: %s\r\n", hg_custom); + Seobeo_Handle *upstream = + Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE); + if (!upstream || upstream->socket < 0) + { + if (upstream) + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial backend unavailable"); + return; + } - if (req_body && req_body[0] != '\0') - req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "Content-Length: %zu\r\n\r\n%s", strlen(req_body), req_body); - else - req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "\r\n"); + char request_header[16384]; + int header_length = snprintf( + request_header, + sizeof(request_header), + "%s /?%s HTTP/1.1\r\n" + "Host: %s:%s\r\n" + "User-Agent: Seobeo/1.0\r\n" + "Connection: close\r\n", + method, + query, + HG_SERVE_HOST, + HG_SERVE_PORT); + if (header_length < 0 || (size_t)header_length >= sizeof(request_header)) + { + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial request headers are too large"); + return; + } - Seobeo_Handle_Queue(p_upstream, (uint8*)request_buf, req_len); - if (Seobeo_Handle_Flush(p_upstream) < 0) +#define APPEND_WIRE_HEADER(...) \ + do { \ + int appended = snprintf( \ + request_header + header_length, \ + sizeof(request_header) - (size_t)header_length, \ + __VA_ARGS__); \ + if (appended < 0 || (size_t)appended >= sizeof(request_header) - (size_t)header_length) { \ + Seobeo_Handle_Destroy(upstream); \ + send_proxy_error(client, 502, "Mercurial request headers are too large"); \ + return; \ + } \ + header_length += appended; \ + } while (0) + + if (hg_argument && hg_argument[0] != '\0') + APPEND_WIRE_HEADER("x-hgarg-1: %s\r\n", hg_argument); + if (content_type && content_type[0] != '\0') + APPEND_WIRE_HEADER("Content-Type: %s\r\n", content_type); + if (body_length > 0) + APPEND_WIRE_HEADER("Content-Length: %zu\r\n", body_length); + APPEND_WIRE_HEADER("\r\n"); +#undef APPEND_WIRE_HEADER + + if (Seobeo_Handle_Queue( + upstream, (const uint8 *)request_header, (uint32)header_length) != 0 || + (body_length > 0 && + Seobeo_Handle_Queue(upstream, (const uint8 *)body, (uint32)body_length) != 0) || + Seobeo_Handle_Flush(upstream) != 0) { - const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 21\r\n\r\nUpstream write failed"; - Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp)); - Seobeo_Handle_Flush(p_client); - Seobeo_Handle_Destroy(p_upstream); + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial backend write failed"); return; } - // Responses - while (1) + boolean response_started = FALSE; + int64_t last_progress = monotonic_milliseconds(); + while (!response_started) { - int r = Seobeo_Handle_Read(p_upstream); - if (r < 0) + int read_result = Seobeo_Handle_Read(upstream); + if (read_result == -2 || read_result < 0) { - Seobeo_Handle_Destroy(p_upstream); + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial backend closed before responding"); return; } - if (p_upstream->read_buffer_len >= 4 && - strstr((char*)p_upstream->read_buffer, "\r\n\r\n") != NULL) + if (read_result > 0) + last_progress = monotonic_milliseconds(); + + size_t header_size = + find_http_header_length(upstream->read_buffer, upstream->read_buffer_len); + if (header_size > 0) + { + (void)header_size; + if (Seobeo_Handle_Queue( + client, upstream->read_buffer, upstream->read_buffer_len) != 0 || + Seobeo_Handle_Flush(client) != 0) + { + Seobeo_Handle_Destroy(upstream); + return; + } + Seobeo_Handle_Consume(upstream, upstream->read_buffer_len); + response_started = TRUE; break; - if (r == 0) - continue; + } + + if (read_result == 0) + { + if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS) + { + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 504, "Mercurial backend response timed out"); + return; + } + usleep(1000); + } } - // TODO: Maybe make this into a separate function instead of internal function as doing this over and over again blows. - char *hdr_end = strstr((char*)p_upstream->read_buffer, "\r\n\r\n"); - if (!hdr_end) - { - Seobeo_Handle_Destroy(p_upstream); - return; - } - size_t hdr_len = hdr_end - (char*)p_upstream->read_buffer + 4; - Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, hdr_len); - Seobeo_Handle_Flush(p_client); - - // All body - size_t body_in_buffer = p_upstream->read_buffer_len - hdr_len; - if (body_in_buffer > 0) + while (TRUE) { - Seobeo_Handle_Queue(p_client, p_upstream->read_buffer + hdr_len, body_in_buffer); - Seobeo_Handle_Flush(p_client); - } - Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len); - while (1) - { - int n = Seobeo_Handle_Read(p_upstream); - if (n > 0) + int read_result = Seobeo_Handle_Read(upstream); + if (read_result == -2) + break; + if (read_result < 0) + break; + if (read_result == 0) { - Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, p_upstream->read_buffer_len); - Seobeo_Handle_Flush(p_client); - Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len); + if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS) + { + Seobeo_Log(SEOBEO_ERROR, "Mercurial response stream timed out\n"); + break; + } + usleep(1000); + continue; } - else if (n == -2) + + last_progress = monotonic_milliseconds(); + if (Seobeo_Handle_Queue( + client, upstream->read_buffer, upstream->read_buffer_len) != 0 || + Seobeo_Handle_Flush(client) != 0) break; - else if (n < 0) - break; + Seobeo_Handle_Consume(upstream, upstream->read_buffer_len); } - Seobeo_Handle_Destroy(p_upstream); + Seobeo_Handle_Destroy(upstream); } -Seobeo_Request_Entry* ApiHgWireProtocol(Seobeo_Request_Entry *req, Dowa_Arena *arena) +Seobeo_Request_Entry *GetReactHome(Seobeo_Request_Entry *request, Dowa_Arena *arena) { - Seobeo_Request_Entry *resp = NULL; - - void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method"); - const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET"; - - void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); - const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : ""; - - void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); - const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : ""; - size_t body_len = strlen(req_body); - - const char *hg_custom = req[7].value; - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: method=%s query=%s body_len=%zu\n", method, query_string, body_len); - - Seobeo_Client_Response *hg_response; + (void)request; + size_t file_size = 0; + char *html = Seobeo_Web_LoadFile("/index.html", &file_size); + if (!html) + return text_response(arena, "500", "text/plain", "Application shell unavailable"); - char hg_path[MAX_PATH]; - snprintf(hg_path, sizeof(hg_path), "/?%s", query_string); - - hg_response = hg_proxy_request(method, hg_path, req_body, hg_custom); - - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", hg_response->body_length); - - Seobeo_Request_Entry *kv = Dowa_HashMap_Get_Ptr(hg_response->headers, "Content-Type"); - - char *status = Dowa_Arena_Allocate(arena, 5); - snprintf(status, 4, "%i", hg_response->status_code); - - // Use binary-safe copy to handle null bytes in mercurial bundle data - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); - - Dowa_HashMap_Push_Arena(resp, "status", status, arena); - Dowa_HashMap_Push_Arena(resp, "content-type", kv->value, arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - - return resp; + Seobeo_Request_Entry *response = NULL; + char *content_length = Dowa_Arena_Allocate(arena, 32); + snprintf(content_length, 32, "%zu", file_size); + Dowa_HashMap_Push_Arena(response, "status", "200", arena); + Dowa_HashMap_Push_Arena(response, "content-type", "text/html", arena); + Dowa_HashMap_Push_Arena(response, "body", html, arena); + Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena); + return response; } -Seobeo_Request_Entry* GetReactHome(Seobeo_Request_Entry *req, Dowa_Arena *arena) +int main(void) { - size_t file_size = 0; - char *html = Seobeo_Web_LoadFile("/index.html", &file_size); - - printf("%s", html); - Seobeo_Request_Entry *resp = NULL; - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/html", arena); - Dowa_HashMap_Push_Arena(resp, "body", html, arena); - return resp; -} - -int main(void) { Seobeo_Router_Init(); - Seobeo_Router_Register("GET", "/", GetReactHome); Seobeo_Router_Register("GET", "/directories", GetReactHome); + Seobeo_Router_Register("GET", "/directory", GetReactHome); Seobeo_Router_Register("GET", "/graph", GetReactHome); + Seobeo_Router_Register("GET", "/changeset/:changeset_id", GetReactHome); Seobeo_Router_Register("GET", "/api/repo/list", ApiListDirectory); Seobeo_Router_Register("GET", "/api/repo/file", ApiGetFile); + Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme); Seobeo_Router_Register("GET", "/api/graph/:graph_id", ApiGetGraph); - Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme); + Seobeo_Router_Register("GET", "/api/changeset/:changeset_id", ApiGetChangeset); - // Use streaming handler for hg wire protocol... Seobeo_Router_Register_Stream("GET", "/repo", StreamHgWireProtocol); Seobeo_Router_Register_Stream("POST", "/repo", StreamHgWireProtocol); printf("Starting on Port 6970...\n"); - - int result = Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1); - + int result = + Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1); Seobeo_Router_Destroy(); - return result; }
--- a/hg-web/src/components/app.tsx Sat Feb 28 21:04:43 2026 -0800 +++ b/hg-web/src/components/app.tsx Sun Aug 02 16:44:06 2026 -0700 @@ -5,13 +5,188 @@ import { Footer } from "hg-web/src/components/footer"; import { ThemeProvider, useTheme } from "hg-web/src/components/theme"; -type Page = 'landing' | 'graph' | 'directory'; +type Page = 'landing' | 'graph' | 'directory' | 'changeset'; type RouteState = { page: Page; graphCommit?: string; graphTip?: string; dirPath?: string; + changesetId?: string; + returnDepth?: number; +} + +type ChangesetDetail = { + node: string; + date: [number, number]; + desc: string; + branch: string; + bookmarks: string[]; + tags: string[]; + user: string; + parents: string[]; + files: Array<{ + file: string; + status: string; + }>; + diff: Array<{ + blockno: number; + lines: Array<{ t: string; n: number; l: string }>; + }>; +}; + +type DiffLine = ChangesetDetail['diff'][number]['lines'][number]; + +type DiffCell = { + lineNumber: number | null; + text: string; + kind: 'context' | 'add' | 'remove' | 'meta'; +}; + +type SideBySideRow = { + left?: DiffCell; + right?: DiffCell; + range?: string; +}; + +function trimDiffLine(line: string): string { + return line.endsWith('\n') ? line.slice(0, -1) : line; +} + +function contentDiffLine(line: DiffLine): string { + const text = trimDiffLine(line.l); + if ((line.t === '+' || line.t === '-' || line.t === '' || line.t === ' ') && + text.startsWith(line.t || ' ')) { + return text.slice(1); + } + return text; +} + +function buildSideBySideRows(lines: DiffLine[]): SideBySideRow[] { + const rows: SideBySideRow[] = []; + let removals: DiffCell[] = []; + let additions: DiffCell[] = []; + let oldLine: number | null = null; + let newLine: number | null = null; + let inHunk = false; + + const flushChanges = () => { + const count = Math.max(removals.length, additions.length); + for (let index = 0; index < count; index++) { + rows.push({ left: removals[index], right: additions[index] }); + } + removals = []; + additions = []; + }; + + for (const line of lines) { + if (line.t === '@') { + flushChanges(); + const range = trimDiffLine(line.l); + const match = range.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + oldLine = match ? Number(match[1]) : null; + newLine = match ? Number(match[2]) : null; + inHunk = true; + rows.push({ range }); + continue; + } + + if (!inHunk && (line.t === '-' || line.t === '+')) { + const cell: DiffCell = { + lineNumber: null, + text: trimDiffLine(line.l), + kind: 'meta', + }; + if (line.t === '-') removals.push(cell); + else additions.push(cell); + continue; + } + + if (line.t === '-') { + removals.push({ + lineNumber: oldLine, + text: contentDiffLine(line), + kind: 'remove', + }); + if (oldLine !== null) oldLine++; + continue; + } + + if (line.t === '+') { + additions.push({ + lineNumber: newLine, + text: contentDiffLine(line), + kind: 'add', + }); + if (newLine !== null) newLine++; + continue; + } + + flushChanges(); + const text = contentDiffLine(line); + rows.push({ + left: { lineNumber: oldLine, text, kind: 'context' }, + right: { lineNumber: newLine, text, kind: 'context' }, + }); + if (oldLine !== null) oldLine++; + if (newLine !== null) newLine++; + } + + flushChanges(); + return rows; +} + +function diffBlockFilename( + block: ChangesetDetail['diff'][number], + fallback?: string, +): string { + if (fallback) return fallback; + const newFileHeader = block.lines.find( + line => line.t === '+' && line.l.startsWith('+++ '), + ); + if (!newFileHeader) return `Diff block ${block.blockno}`; + return trimDiffLine(newFileHeader.l).replace(/^\+\+\+ (?:b\/)?/, '').split('\t')[0]; +} + +function SideBySideDiff({ + block, + filename, +}: { + block: ChangesetDetail['diff'][number]; + filename: string; +}) { + const rows = buildSideBySideRows(block.lines); + return ( + <div className="side-by-side-diff"> + <div className="diff-file-header">{filename}</div> + <div className="diff-column-headings"> + <span>Before</span> + <span>After</span> + </div> + <div className="diff-grid"> + {rows.map((row, index) => ( + row.range ? ( + <div className="diff-range-row" key={`range-${index}`}>{row.range}</div> + ) : ( + <React.Fragment key={`row-${index}`}> + <span className={`diff-side-number diff-${row.left?.kind || 'empty'}`}> + {row.left?.lineNumber ?? ''} + </span> + <code className={`diff-side-code diff-left diff-${row.left?.kind || 'empty'}`}> + {row.left?.text ?? ''} + </code> + <span className={`diff-side-number diff-column-divider diff-${row.right?.kind || 'empty'}`}> + {row.right?.lineNumber ?? ''} + </span> + <code className={`diff-side-code diff-right diff-${row.right?.kind || 'empty'}`}> + {row.right?.text ?? ''} + </code> + </React.Fragment> + ) + ))} + </div> + </div> + ); } // Icons @@ -40,6 +215,18 @@ function parseRoute(): RouteState { const params = new URLSearchParams(window.location.search); const pathname = window.location.pathname; + const changesetMatch = pathname.match(/^\/changeset\/([^/]+)$/); + + if (changesetMatch) { + try { + const changesetId = decodeURIComponent(changesetMatch[1]); + if (/^(?:[0-9a-f]{1,40}|tip)$/i.test(changesetId)) { + return { page: 'changeset', changesetId }; + } + } catch { + return { page: 'landing' }; + } + } if (pathname.startsWith('/graph') || params.has('graph')) { return { @@ -70,18 +257,29 @@ case 'directory': if (state.dirPath) params.set('path', state.dirPath); return `/directory${params.toString() ? '?' + params.toString() : ''}`; + case 'changeset': + return state.changesetId ? `/changeset/${encodeURIComponent(state.changesetId)}` : '/graph'; default: return '/'; } } +function isRouteState(value: unknown): value is RouteState { + if (!value || typeof value !== 'object' || !('page' in value)) return false; + return ['landing', 'graph', 'directory', 'changeset'].includes( + String((value as { page: unknown }).page), + ); +} + // Landing Page Component function LandingPage({ onNavigateToGraph, onNavigateToDirectory, + onNavigateToChangeset, }: { onNavigateToGraph: () => void; onNavigateToDirectory: (path?: string) => void; + onNavigateToChangeset: (node: string) => void; }) { const [directories, setDirectories] = useState<any[]>([]); const [files, setFiles] = useState<any[]>([]); @@ -128,9 +326,7 @@ <Graph data={graphData} maxRows={8} - onCommitClick={(node) => { - console.log('Clicked commit:', node); - }} + onCommitClick={onNavigateToChangeset} /> ) : ( <div className="empty-state">Failed to load commits</div> @@ -186,10 +382,12 @@ onBack, initialCommit, initialTip, + onOpenChangeset, }: { onBack: () => void; initialCommit?: string; initialTip?: string; + onOpenChangeset: (node: string) => void; }) { const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({ initialCommit: initialCommit || null, @@ -209,7 +407,7 @@ return ( <div> <div className="page-header"> - <button className="back-button" onClick={onBack}> + <button className="back-button" onClick={onBack} aria-label="Back"> ← Back </button> <span className="page-title">Commit Graph</span> @@ -239,14 +437,127 @@ loading={loading} hasMore={hasMore} onLoadMore={loadMore} - onCommitClick={(node) => { - console.log('Clicked commit:', node); - }} + onCommitClick={onOpenChangeset} /> </div> ); } +function ChangesetPage({ + changesetId, + onBack, + onOpenChangeset, +}: { + changesetId: string; + onBack: () => void; + onOpenChangeset: (node: string) => void; +}) { + const [changeset, setChangeset] = useState<ChangesetDetail | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + const controller = new AbortController(); + + setLoading(true); + setError(null); + setChangeset(null); + + fetch(`/api/changeset/${encodeURIComponent(changesetId)}`, { signal: controller.signal }) + .then(async response => { + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Unable to load changeset (${response.status})`); + } + return response.json() as Promise<ChangesetDetail>; + }) + .then(setChangeset) + .catch(err => { + if (err.name !== 'AbortError') setError(err.message); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [changesetId]); + + return ( + <div> + <div className="page-header"> + <button className="back-button" onClick={onBack} aria-label="Back"> + ← Back + </button> + <span className="page-title">Changeset</span> + </div> + + {loading && <div className="loading-state">Loading changeset...</div>} + {error && <div className="error-message">Error: {error}</div>} + + {changeset && ( + <article className="changeset-paper"> + <div className="changeset-heading"> + <code>{changeset.node}</code> + <span className="changeset-branch">{changeset.branch}</span> + </div> + <h2>{changeset.desc}</h2> + <div className="changeset-meta"> + <span>{changeset.user}</span> + <time dateTime={new Date(changeset.date[0] * 1000).toISOString()}> + {new Date(changeset.date[0] * 1000).toLocaleString()} + </time> + </div> + + {(changeset.bookmarks.length > 0 || changeset.tags.length > 0) && ( + <div className="changeset-labels"> + {changeset.bookmarks.map(bookmark => <span key={`bookmark-${bookmark}`}>{bookmark}</span>)} + {changeset.tags.map(tag => <span key={`tag-${tag}`}>{tag}</span>)} + </div> + )} + + {changeset.parents.length > 0 && ( + <div className="changeset-parents"> + <strong>Parents</strong> + {changeset.parents.map(parent => ( + <button type="button" key={parent} onClick={() => onOpenChangeset(parent)}> + {parent.substring(0, 12)} + </button> + ))} + </div> + )} + + {changeset.files.length > 0 && ( + <div className="changeset-files"> + <strong>Files</strong> + {changeset.files.map(file => ( + <code key={file.file}> + <span className={`changeset-file-status status-${file.status}`}> + {file.status} + </span> + {file.file} + </code> + ))} + </div> + )} + + <section className="changeset-diff" aria-label="Changeset diff"> + <h3>Diff</h3> + {changeset.diff.length === 0 ? ( + <div className="empty-state">No textual changes in this changeset.</div> + ) : changeset.diff.map((block, index) => ( + <SideBySideDiff + key={block.blockno} + block={block} + filename={diffBlockFilename(block, changeset.files[index]?.file)} + /> + ))} + </section> + </article> + )} + </div> + ); +} + // Directory Page Component function DirectoryPage({ onBack, @@ -260,7 +571,7 @@ return ( <div> <div className="page-header"> - <button className="back-button" onClick={onBack}> + <button className="back-button" onClick={onBack} aria-label="Back"> ← Back </button> <span className="page-title">Repository Files</span> @@ -281,8 +592,8 @@ // Handle browser back/forward useEffect(() => { - const handlePopState = () => { - setRoute(parseRoute()); + const handlePopState = (event: PopStateEvent) => { + setRoute(isRouteState(event.state) ? event.state : parseRoute()); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); @@ -306,6 +617,21 @@ navigate({ page: 'directory', dirPath: path || '' }); }, [navigate]); + const navigateToChangeset = useCallback((changesetId: string) => { + const returnDepth = route.page === 'changeset' + ? (route.returnDepth ?? 0) + 1 + : 1; + navigate({ page: 'changeset', changesetId, returnDepth }); + }, [navigate, route.page, route.returnDepth]); + + const navigateBackFromChangeset = useCallback(() => { + if (route.returnDepth !== undefined && route.returnDepth > 0) { + window.history.go(-route.returnDepth); + return; + } + navigateToGraph(); + }, [navigateToGraph, route.returnDepth]); + const handleDirectoryPathChange = useCallback((path: string) => { // Update URL without full navigation const params = new URLSearchParams(); @@ -333,7 +659,7 @@ Home </button> <button - className={`nav-tab ${route.page === 'graph' ? 'active' : ''}`} + className={`nav-tab ${route.page === 'graph' || route.page === 'changeset' ? 'active' : ''}`} onClick={() => navigateToGraph()} > <GraphIcon /> @@ -353,6 +679,7 @@ <LandingPage onNavigateToGraph={() => navigateToGraph()} onNavigateToDirectory={navigateToDirectory} + onNavigateToChangeset={navigateToChangeset} /> )} @@ -361,6 +688,7 @@ onBack={navigateToLanding} initialCommit={route.graphCommit} initialTip={route.graphTip} + onOpenChangeset={navigateToChangeset} /> )} @@ -372,6 +700,14 @@ /> )} + {route.page === 'changeset' && route.changesetId && ( + <ChangesetPage + changesetId={route.changesetId} + onBack={navigateBackFromChangeset} + onOpenChangeset={navigateToChangeset} + /> + )} + <Footer /> </div> );
--- a/hg-web/src/components/directory-browser.tsx Sat Feb 28 21:04:43 2026 -0800 +++ b/hg-web/src/components/directory-browser.tsx Sun Aug 02 16:44:06 2026 -0700 @@ -19,9 +19,22 @@ 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', - 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample' + 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample', + 'bzl', 'bazel' +]); + +const BAZEL_FILENAMES = new Set([ + 'build', 'build.bazel', 'module.bazel', 'workspace', 'workspace.bazel' ]); +type StaticPreviewKind = 'image' | 'video' | 'audio' | 'pdf'; + +const IMAGE_EXTENSIONS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg' +]); +const VIDEO_EXTENSIONS = new Set(['mp4', 'm4v', 'webm', 'mov', 'ogv']); +const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac']); + // Prefetch cache const prefetchCache = new Map<string, Promise<any>>(); @@ -30,6 +43,7 @@ const basename = filename.toLowerCase(); return CODE_EXTENSIONS.has(ext) || CODE_EXTENSIONS.has(basename) || + BAZEL_FILENAMES.has(basename) || basename === 'dockerfile' || basename === 'makefile' || basename.startsWith('.'); @@ -40,6 +54,15 @@ return ext === 'md' || ext === 'markdown'; } +function getStaticPreviewKind(filename: string): StaticPreviewKind | null { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + if (IMAGE_EXTENSIONS.has(ext)) return 'image'; + if (VIDEO_EXTENSIONS.has(ext)) return 'video'; + if (AUDIO_EXTENSIONS.has(ext)) return 'audio'; + if (ext === 'pdf') return 'pdf'; + return null; +} + function prefetchDirectory(path: string): void { const cacheKey = `dir:${path}`; if (prefetchCache.has(cacheKey)) return; @@ -155,6 +178,10 @@ const getLanguage = () => { const ext = filename.split('.').pop()?.toLowerCase() || ''; + const basename = filename.toLowerCase(); + if (BAZEL_FILENAMES.has(basename) || ext === 'bzl' || ext === 'bazel') { + return 'python'; + } const langMap: Record<string, string> = { js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', @@ -276,6 +303,81 @@ ); } +function StaticFileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { + const filename = filePath.split('/').pop() || filePath; + const previewKind = getStaticPreviewKind(filename); + const fileUrl = `${API_BASE}/file?path=${encodeURIComponent(filePath)}`; + const [failed, setFailed] = useState(false); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + return ( + <div className="file-viewer-overlay" onClick={onClose}> + <div + className="file-viewer static-file-viewer" + role="dialog" + aria-modal="true" + aria-label={`Preview ${filename}`} + onClick={(event) => event.stopPropagation()} + > + <div className="file-viewer-header"> + <span className="file-viewer-title"> + <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> + {filename} + </span> + <span className="file-viewer-actions"> + <a href={fileUrl} download={filename}>Download</a> + <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> + <img className="icon-invert" src={ICONS.close} alt="Close" /> + </button> + </span> + </div> + <div className="file-viewer-content static-file-preview"> + {failed && <div className="error-message">Unable to preview this file.</div>} + {!failed && previewKind === 'image' && ( + <img + className="static-file-image" + src={fileUrl} + alt={filename} + onError={() => setFailed(true)} + /> + )} + {!failed && previewKind === 'video' && ( + <video + className="static-file-video" + src={fileUrl} + controls + onError={() => setFailed(true)} + /> + )} + {!failed && previewKind === 'audio' && ( + <audio + className="static-file-audio" + src={fileUrl} + controls + onError={() => setFailed(true)} + /> + )} + {!failed && previewKind === 'pdf' && ( + <iframe + className="static-file-pdf" + src={fileUrl} + title={filename} + onError={() => setFailed(true)} + /> + )} + </div> + </div> + </div> + ); +} + /** * Component: FileList */ @@ -338,7 +440,7 @@ e.preventDefault(); if (isDir) { onNavigate(item.abspath); - } else if (isCodeFile(item.basename)) { + } else if (isCodeFile(item.basename) || getStaticPreviewKind(item.basename)) { onOpenFile(item.abspath); } else { window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); @@ -429,6 +531,7 @@ const [error, setError] = useState<string | null>(null); const [loading, setLoading] = useState(false); const [viewingFile, setViewingFile] = useState<string | null>(null); + const requestGeneration = useRef(0); // Sync with initialPath prop useEffect(() => { @@ -436,8 +539,9 @@ }, [initialPath]); useEffect(() => { - fetchDirectory(currentPath); - fetchReadme(currentPath); + const generation = ++requestGeneration.current; + fetchDirectory(currentPath, generation); + fetchReadme(currentPath, generation); }, [currentPath]); const navigate = useCallback((path: string) => { @@ -445,7 +549,7 @@ onPathChange?.(path); }, [onPathChange]); - const fetchDirectory = async (path: string) => { + const fetchDirectory = async (path: string, generation: number) => { setLoading(true); setError(null); try { @@ -468,26 +572,30 @@ throw new Error(data.error); } - setContent({ - files: data?.files || [], - directories: data?.directories || [] - }); + if (generation === requestGeneration.current) { + setContent({ + files: data?.files || [], + directories: data?.directories || [] + }); + } } catch (err: any) { console.error('Error loading directory:', err); - setError(err.message); + if (generation === requestGeneration.current) setError(err.message); } finally { - setLoading(false); + if (generation === requestGeneration.current) setLoading(false); } }; - const fetchReadme = async (path: string) => { - setReadme(null); - const readmePath = path ? `${path}/README.md` : 'README.md'; + const fetchReadme = async (path: string, generation: number) => { + if (generation === requestGeneration.current) setReadme(null); try { - const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); + const url = path + ? `${API_BASE}/readme?path=${encodeURIComponent(path)}` + : `${API_BASE}/readme`; + const response = await fetch(url); if (response.ok) { const text = await response.text(); - setReadme(text); + if (generation === requestGeneration.current) setReadme(text || null); } } catch (err) { // Readme is optional @@ -528,6 +636,8 @@ {viewingFile && ( isMarkdownFile(viewingFile) ? ( <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> + ) : getStaticPreviewKind(viewingFile) ? ( + <StaticFileViewer filePath={viewingFile} onClose={handleCloseFile} /> ) : ( <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> )
--- a/hg-web/src/components/graph.tsx Sat Feb 28 21:04:43 2026 -0800 +++ b/hg-web/src/components/graph.tsx Sun Aug 02 16:44:06 2026 -0700 @@ -177,17 +177,11 @@ const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => { const canvasRef = useRef<HTMLCanvasElement>(null); const containerRef = useRef<HTMLDivElement>(null); + const [assetError, setAssetError] = useState<string | null>(null); const changesets = useMemo(() => maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]); - let pencilPattern; - const img = new Image(); - img.src = "http://localhost:6970/pencil_lines.png"; - - const pandaImg = new Image(); - pandaImg.src = "http://localhost:6970/panda.png"; - useEffect(() => { const canvas = canvasRef.current; if (!canvas || !changesets.length) return; @@ -195,20 +189,11 @@ const ctx = canvas.getContext('2d'); if (!ctx) return; - // Grab colors from CSS variables or defaults - const getColors = () => { - const s = getComputedStyle(document.documentElement); - return [ - s.getPropertyValue('--graph-1').trim() || '#4dabf7', - s.getPropertyValue('--graph-2').trim() || '#63e6be', - s.getPropertyValue('--graph-3').trim() || '#ffbc42', - s.getPropertyValue('--graph-4').trim() || '#b197fc', - s.getPropertyValue('--graph-5').trim() || '#ff8787', - s.getPropertyValue('--graph-6').trim() || '#f06595', - ]; - }; - - const colors = getColors(); + let cancelled = false; + let pencilPattern: CanvasPattern | null = null; + let loadedAssets = 0; + const pencilImage = new Image(); + const pandaImage = new Image(); const dpr = window.devicePixelRatio || 1; const maxCol = Math.max(...changesets.map(cs => cs.col), 0); const canvasWidth = (maxCol + 2) * colWidth; @@ -241,13 +226,35 @@ // Pass 2: Draw Commit Nodes changesets.forEach((cs, i) => { const x = getX(cs.col), y = getY(i); - ctx.drawImage(pandaImg, x-10, y-10, 20, 20); + ctx.drawImage(pandaImage, x-10, y-10, 20, 20); }); }; - img.onload = () => { - pencilPattern = ctx.createPattern(img, "repeat")!; - renderCanvas(); + const handleAssetLoad = () => { + loadedAssets++; + if (loadedAssets !== 2 || cancelled) return; + pencilPattern = ctx.createPattern(pencilImage, "repeat"); + renderCanvas(); + }; + + const handleAssetError = () => { + if (!cancelled) setAssetError('Unable to load the graph artwork.'); + }; + + setAssetError(null); + pencilImage.onload = handleAssetLoad; + pencilImage.onerror = handleAssetError; + pandaImage.onload = handleAssetLoad; + pandaImage.onerror = handleAssetError; + pencilImage.src = "/pencil_lines.png"; + pandaImage.src = "/panda.png"; + + return () => { + cancelled = true; + pencilImage.onload = null; + pencilImage.onerror = null; + pandaImage.onload = null; + pandaImage.onerror = null; }; }, [changesets]); @@ -266,45 +273,38 @@ }, [onLoadMore, hasMore, loading]); return ( - <div style={{ display: 'flex', flexDirection: 'column', height: '100%', backgroundImage: 'url("/hg-web-background.jpg")', fontFamily: 'monospace' }}> + <div className="graph-container"> + {assetError && <div className="error-message">{assetError}</div>} <div ref={containerRef} - style={{ display: 'flex', flex: 1, overflowY: 'auto', position: 'relative' }} + className="graph-wrapper" > - {/* Graph Column - Sticky to keep lines aligned with text during scroll */} - <div style={{ position: 'sticky', top: 0, height: 'fit-content', zIndex: 10, borderRight: '1px solid #333' }}> + <div className="graph-canvas-column"> <canvas ref={canvasRef} style={{ display: 'block' }} /> </div> - {/* Details Column */} - <div style={{ flex: 1 }}> + <div className="graph-details-column"> {changesets.map((cs) => ( - <div - key={cs.node} - style={{ - height: rowHeight, - display: 'flex', - alignItems: 'center', - padding: '0 15px', - borderBottom: '1px solid #252525', - cursor: 'pointer', - fontSize: '13px', - whiteSpace: 'nowrap' - }} + <button + type="button" + key={cs.node} + className="graph-row" onClick={() => onCommitClick?.(cs.node)} - onMouseEnter={(e) => (e.currentTarget.style.background = '#222')} - onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} + aria-label={`Open changeset ${cs.node.substring(0, 12)}: ${cs.desc}`} > - <span style={{ color: '#4dabf7', width: '90px', flexShrink: 0 }}>{cs.node.substring(0, 12)}</span> - <span style={{ color: '#eee', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', paddingRight: '20px' }}>{cs.desc}</span> - <span style={{ color: '#888', width: '150px', textAlign: 'right' }}>{cs.user.split(' <')[0]}</span> - </div> + <span className="graph-row-meta"> + <span className="graph-hash">{cs.node.substring(0, 12)}</span> + <span className="graph-user">{cs.user.split(' <')[0]}</span> + {cs.branch && <span className="graph-branch">{cs.branch}</span>} + </span> + <span className="graph-desc">{cs.desc}</span> + </button> ))} <div id="infinite-scroll-sentinel" style={{ height: '50px' }} /> </div> </div> - {loading && <div style={{ padding: '10px', textAlign: 'center', color: '#888', fontSize: '12px', background: '#111' }}>Loading repository history...</div>} + {loading && <div className="graph-loading-row">Loading repository history...</div>} </div> ); };
--- a/hg-web/src/index.css Sat Feb 28 21:04:43 2026 -0800 +++ b/hg-web/src/index.css Sun Aug 02 16:44:06 2026 -0700 @@ -270,13 +270,15 @@ align-items: flex-start; max-height: 600px; overflow-y: auto; + position: relative; } .graph-canvas-column { flex-shrink: 0; - background: var(--bg); position: sticky; left: 0; + z-index: 1; + border-right: 1px solid var(--border); } .graph-details-column { @@ -285,11 +287,16 @@ } .graph-row { + width: 100%; height: 40px; display: flex; flex-direction: column; justify-content: center; padding: 0 12px; + background: transparent; + color: inherit; + text-align: left; + border: 0; border-bottom: 1px solid var(--border); font-size: 12px; cursor: pointer; @@ -300,6 +307,12 @@ background: var(--hover); } +.graph-row:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + background: var(--hover); +} + .graph-row-meta { display: flex; gap: 10px; @@ -365,6 +378,204 @@ } /* =========================================== + Changeset Detail + =========================================== */ +.changeset-paper { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 24px; +} + +.changeset-heading, +.changeset-meta, +.changeset-parents, +.changeset-files, +.changeset-labels { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.changeset-heading { + justify-content: space-between; + margin-bottom: 12px; +} + +.changeset-heading code { + overflow-wrap: anywhere; +} + +.changeset-paper h2 { + margin-bottom: 8px; +} + +.changeset-meta { + color: var(--text-secondary); + justify-content: space-between; + margin-bottom: 16px; +} + +.changeset-branch, +.changeset-labels span { + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 8px; + font-size: 12px; +} + +.changeset-labels, +.changeset-parents, +.changeset-files { + margin: 12px 0; +} + +.changeset-parents button { + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font-family: monospace; +} + +.changeset-parents button:hover { + text-decoration: underline; +} + +.changeset-parents button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.changeset-files code { + display: inline-flex; + align-items: center; + gap: 6px; + overflow-wrap: anywhere; +} + +.changeset-file-status { + color: var(--text-secondary); + font-size: 10px; + text-transform: uppercase; +} + +.changeset-file-status.status-added { + color: var(--success); +} + +.changeset-file-status.status-removed { + color: var(--danger); +} + +.changeset-diff { + margin-top: 24px; +} + +.changeset-diff h3 { + margin-bottom: 10px; +} + +.side-by-side-diff { + margin-bottom: 18px; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-code); +} + +.diff-file-header { + padding: 9px 12px; + border-bottom: 1px solid var(--border); + background: var(--bg-subtle); + font-family: monospace; + font-weight: 600; +} + +.diff-column-headings { + display: grid; + grid-template-columns: minmax(380px, 1fr) minmax(380px, 1fr); + min-width: 760px; + border-bottom: 1px solid var(--border); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.diff-column-headings span { + padding: 6px 12px; +} + +.diff-column-headings span + span { + border-left: 1px solid var(--border); +} + +.diff-grid { + display: grid; + grid-template-columns: 52px minmax(328px, 1fr) 52px minmax(328px, 1fr); + min-width: 760px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.5; +} + +.diff-side-number, +.diff-side-code { + min-height: 22px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); +} + +.diff-side-number { + padding: 2px 8px; + color: var(--text-secondary); + text-align: right; + user-select: none; + border-right: 1px solid var(--border); +} + +.diff-side-code { + display: block; + margin: 0; + padding: 2px 10px; + border-radius: 0; + background: transparent; + white-space: pre; +} + +.diff-column-divider { + border-left: 1px solid var(--border); +} + +.diff-add { + background: color-mix(in srgb, var(--success) 18%, transparent); +} + +.diff-remove { + background: color-mix(in srgb, var(--danger) 18%, transparent); +} + +.diff-empty { + background: color-mix(in srgb, var(--bg-subtle) 70%, transparent); +} + +.diff-meta { + color: var(--text-secondary); + background: var(--bg-subtle); +} + +.diff-range-row { + grid-column: 1 / -1; + padding: 4px 10px; + color: var(--accent); + background: var(--bg-subtle); + border-bottom: 1px solid var(--border); + white-space: pre; +} + +/* =========================================== Common States =========================================== */ .empty-state { @@ -603,11 +814,53 @@ opacity: 0.7; } +.file-viewer-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.file-viewer-actions a { + font-size: 13px; +} + .file-viewer-content { overflow: auto; flex: 1; } +.static-file-viewer { + max-width: 1100px; +} + +.static-file-preview { + min-height: 240px; + padding: 20px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-subtle); +} + +.static-file-image, +.static-file-video { + display: block; + max-width: 100%; + max-height: 75vh; + object-fit: contain; +} + +.static-file-audio { + width: min(100%, 640px); +} + +.static-file-pdf { + width: 100%; + height: 75vh; + border: 0; + background: #fff; +} + .file-viewer-content pre { margin: 0; padding: 16px;
--- a/markdown_converter/markdown_to_html.c Sat Feb 28 21:04:43 2026 -0800 +++ b/markdown_converter/markdown_to_html.c Sun Aug 02 16:44:06 2026 -0700 @@ -61,14 +61,6 @@ buf->length += len; } -static void buffer_append_n(StringBuffer *buf, const char *str, size_t n) -{ - buffer_grow(buf, n); - memcpy(buf->data + buf->length, str, n); - buf->length += n; - buf->data[buf->length] = '\0'; -} - static void buffer_append_char(StringBuffer *buf, char c) { buffer_grow(buf, 1); @@ -76,6 +68,39 @@ buf->data[buf->length] = '\0'; } +static void buffer_append_html_escaped_n(StringBuffer *buf, const char *text, size_t len) +{ + for (size_t i = 0; i < len; i++) { + switch (text[i]) { + case '&': buffer_append(buf, "&"); break; + case '<': buffer_append(buf, "<"); break; + case '>': buffer_append(buf, ">"); break; + case '"': buffer_append(buf, """); break; + case '\'': buffer_append(buf, "'"); break; + default: buffer_append_char(buf, text[i]); break; + } + } +} + +static int is_safe_url(const char *url, size_t len, int is_image) +{ + if (len == 0) return 0; + + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)url[i]; + if (iscntrl(c) || isspace(c)) return 0; + } + + const char *colon = memchr(url, ':', len); + if (!colon) return 1; + + size_t scheme_len = (size_t)(colon - url); + if (scheme_len == 4 && strncasecmp(url, "http", scheme_len) == 0) return 1; + if (scheme_len == 5 && strncasecmp(url, "https", scheme_len) == 0) return 1; + if (!is_image && scheme_len == 6 && strncasecmp(url, "mailto", scheme_len) == 0) return 1; + return 0; +} + static void buffer_free(StringBuffer *buf) { if (buf) { @@ -160,28 +185,6 @@ return 1; } -// Check if line starts with a specific HTML tag (e.g., "script", "style") -static int is_html_tag(const char *line, const char *tag) -{ - line = skip_whitespace(line); - if (*line != '<') return 0; - line++; - - // Skip optional / - int is_closing = 0; - if (*line == '/') { - is_closing = 1; - line++; - } - - size_t tag_len = strlen(tag); - if (strncasecmp(line, tag, tag_len) != 0) return 0; - - char next = line[tag_len]; - // Tag must be followed by space, >, or end for closing tags - return next == '>' || next == ' ' || next == '\t' || next == '\n' || next == '\0'; -} - // Check if line is ordered list item static int is_ordered_list(const char *line) { @@ -359,11 +362,16 @@ while (url_end < len && text[url_end] != ')') url_end++; if (url_end < len) { - buffer_append(buf, "<a href=\""); - buffer_append_n(buf, text + url_start, url_end - url_start); - buffer_append(buf, "\">"); - buffer_append_n(buf, text + link_start, link_end - link_start); - buffer_append(buf, "</a>"); + size_t url_len = url_end - url_start; + if (is_safe_url(text + url_start, url_len, 0)) { + buffer_append(buf, "<a href=\""); + buffer_append_html_escaped_n(buf, text + url_start, url_len); + buffer_append(buf, "\">"); + process_inline(buf, text + link_start, link_end - link_start); + buffer_append(buf, "</a>"); + } else { + process_inline(buf, text + link_start, link_end - link_start); + } i = url_end + 1; continue; } @@ -382,11 +390,16 @@ while (url_end < len && text[url_end] != ')') url_end++; if (url_end < len) { - buffer_append(buf, "<img src=\""); - buffer_append_n(buf, text + url_start, url_end - url_start); - buffer_append(buf, "\" alt=\""); - buffer_append_n(buf, text + alt_start, alt_end - alt_start); - buffer_append(buf, "\">"); + size_t url_len = url_end - url_start; + if (is_safe_url(text + url_start, url_len, 1)) { + buffer_append(buf, "<img src=\""); + buffer_append_html_escaped_n(buf, text + url_start, url_len); + buffer_append(buf, "\" alt=\""); + buffer_append_html_escaped_n(buf, text + alt_start, alt_end - alt_start); + buffer_append(buf, "\">"); + } else { + buffer_append_html_escaped_n(buf, text + alt_start, alt_end - alt_start); + } i = url_end + 1; continue; } @@ -449,25 +462,14 @@ if (end < len) { buffer_append(buf, "<code>"); - buffer_append_n(buf, text + start, end - start); + buffer_append_html_escaped_n(buf, text + start, end - start); buffer_append(buf, "</code>"); i = end + 1; continue; } } - // This might not be needed for now. - // HTML escape special characters - // if (text[i] == '<') { - // buffer_append(buf, "<"); - // } else if (text[i] == '>') { - // buffer_append(buf, ">"); - // } else if (text[i] == '&') { - // buffer_append(buf, "&"); - // } else { - // buffer_append_char(buf, text[i]); - // } - buffer_append_char(buf, text[i]); + buffer_append_html_escaped_n(buf, text + i, 1); i++; } } @@ -759,48 +761,11 @@ } } - // HTML block - pass through unchanged + // Repository markdown is untrusted. Render raw HTML as text. if (is_html_block_start(line)) { - // Check if it's a script or style tag that needs special handling - int is_script = is_html_tag(line, "script"); - int is_style = is_html_tag(line, "style"); - - if (is_script || is_style) { - const char *end_tag = is_script ? "</script>" : "</style>"; - - // Output the opening line - buffer_append(buf, line); - buffer_append_char(buf, '\n'); - - free(line); - if (*ptr == '\n') ptr++; - - // Collect content until closing tag - while (*ptr) { - line_start = ptr; - while (*ptr && *ptr != '\n') ptr++; - line_len = ptr - line_start; - - line = (char *)malloc(line_len + 1); - if (!line) break; - memcpy(line, line_start, line_len); - line[line_len] = '\0'; - - buffer_append(buf, line); - buffer_append_char(buf, '\n'); - - int found_end = (strstr(line, end_tag) != NULL); - free(line); - if (*ptr == '\n') ptr++; - - if (found_end) break; - } - continue; - } - - // Regular HTML tag - just pass through the line - buffer_append(buf, line); - buffer_append_char(buf, '\n'); + buffer_append(buf, "<p>"); + process_inline(buf, line, line_len); + buffer_append(buf, "</p>"); free(line); if (*ptr == '\n') ptr++; continue;
--- a/markdown_converter/tests/BUILD Sat Feb 28 21:04:43 2026 -0800 +++ b/markdown_converter/tests/BUILD Sun Aug 02 16:44:06 2026 -0700 @@ -1,3 +1,11 @@ +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +cc_test( + name = "markdown_to_html_test", + srcs = ["markdown_to_html_test.c"], + deps = ["//markdown_converter:markdown_to_html_c"], +) + # load("//gui_ze:gui_ze.bzl", "bun_run", "move_files_into_dir") # # # Test for WASM module (run with: bazel test //markdown_converter:markdown_to_html_wasm_test)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/markdown_converter/tests/markdown_to_html_test.c Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,32 @@ +#include "markdown_converter/markdown_to_html.h" + +#include <stdio.h> +#include <string.h> + +static int failures = 0; + +static void expect_equal(const char *name, const char *markdown, const char *expected) +{ + char *actual = markdown_to_html(markdown); + if (!actual || strcmp(actual, expected) != 0) { + fprintf(stderr, "%s\nexpected: %s\nactual: %s\n", name, expected, actual ? actual : "(null)"); + failures++; + } + markdown_free(actual); +} + +int main(void) +{ + expect_equal("normal link", "[link](https://example.com)", + "<p><a href=\"https://example.com\">link</a></p>"); + expect_equal("escape paragraph", "a & <b>", "<p>a & <b></p>"); + expect_equal("escape raw script", "<script>alert('x')</script>", + "<p><script>alert('x')</script></p>"); + expect_equal("reject script link", "[open](javascript:alert)", "<p>open</p>"); + expect_equal("reject data image", "", "<p>preview</p>"); + expect_equal("escape link attribute", "[link](https://example.com/\"x)", + "<p><a href=\"https://example.com/"x\">link</a></p>"); + expect_equal("escape inline code", "`<script>`", "<p><code><script></code></p>"); + + return failures == 0 ? 0 : 1; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schwab_trader/BUILD Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,66 @@ +load("@rules_python//python:py_binary.bzl", "py_binary") +load("@rules_python//python:py_library.bzl", "py_library") +load("@rules_python//python:py_test.bzl", "py_test") + +py_library( + name = "schwab_client", + srcs = [ + "__init__.py", + "schwab_client.py", + ], + visibility = ["//visibility:public"], +) + +py_library( + name = "schwab_dashboard_lib", + srcs = [ + "__init__.py", + "dashboard.py", + ], + deps = [":schwab_client"], +) + +py_library( + name = "schwab_dashboard_server_lib", + srcs = [ + "__init__.py", + "dashboard_server.py", + ], + deps = [ + ":schwab_client", + ":schwab_dashboard_lib", + ], +) + +py_binary( + name = "schwab_cli", + srcs = ["schwab_cli.py"], + main = "schwab_cli.py", + deps = [":schwab_client"], +) + +py_binary( + name = "schwab_dashboard", + srcs = ["dashboard_server.py"], + main = "dashboard_server.py", + deps = [ + ":schwab_client", + ":schwab_dashboard_lib", + ], +) + +py_test( + name = "schwab_client_test", + srcs = ["schwab_client_test.py"], + deps = [":schwab_client"], +) + +py_test( + name = "dashboard_test", + srcs = ["dashboard_test.py"], + deps = [ + ":schwab_client", + ":schwab_dashboard_lib", + ":schwab_dashboard_server_lib", + ], +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schwab_trader/README.md Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,160 @@ +# Schwab Trader + +Small Bazel-built helper for the Schwab Trader API. This project is only plumbing: it helps authenticate, inspect accounts, build explicit user-specified stock orders, and submit them only when a live-trade confirmation flag is present. + +It does not recommend trades, choose symbols, allocate portfolio risk, or automate a strategy. + +## Can Schwab accounts be traded by API? + +Yes. Schwab provides the Trader API through the Schwab Developer Portal. The flow is OAuth 2.0: + +1. Create an app in the Schwab Developer Portal. +2. Get an app key and app secret. +3. Register a redirect URI. +4. Open the OAuth authorization URL and log in through Schwab. +5. Exchange the returned `code` for an access token and refresh token. +6. Use the access token against `https://api.schwabapi.com/trader/v1`. + +Do not use username/password scraping or browser automation. The supported path is OAuth tokens. + +Useful endpoints this project targets: + +- `GET https://api.schwabapi.com/trader/v1/accounts/accountNumbers` +- `GET https://api.schwabapi.com/trader/v1/accounts` +- `GET https://api.schwabapi.com/trader/v1/accounts/{accountHash}` +- `POST https://api.schwabapi.com/trader/v1/accounts/{accountHash}/orders` + +Schwab uses account hashes for trading API calls. Fetch them with `account-numbers` before placing any order. + +## Build and test + +From the repo root: + +```bash +bazel build //schwab_trader:schwab_cli +bazel test //schwab_trader:schwab_client_test +bazel build //schwab_trader:schwab_dashboard +bazel test //schwab_trader:dashboard_test +``` + +## Configuration + +Set these environment variables: + +```bash +export SCHWAB_APP_KEY="your-schwab-app-key" +export SCHWAB_APP_SECRET="your-schwab-app-secret" +export SCHWAB_REDIRECT_URI="https://127.0.0.1" +export SCHWAB_TOKEN_FILE="$HOME/.config/zenbu/schwab_tokens.json" +``` + +`SCHWAB_TOKEN_FILE` is optional and defaults to `~/.config/zenbu/schwab_tokens.json`. Token files are written with `0600` permissions. + +## OAuth bootstrap + +Print the Schwab login URL: + +```bash +bazel run //schwab_trader:schwab_cli -- auth-url +``` + +Open it, log in through Schwab, authorize the app, then copy the full callback URL or just its `code` parameter: + +```bash +bazel run //schwab_trader:schwab_cli -- token --code 'https://127.0.0.1/?code=...' +``` + +Refresh later: + +```bash +bazel run //schwab_trader:schwab_cli -- refresh +``` + +## Account discovery + +```bash +bazel run //schwab_trader:schwab_cli -- account-numbers +bazel run //schwab_trader:schwab_cli -- accounts --positions +``` + +## Order dry run + +Build a stock order payload without sending it: + +```bash +bazel run //schwab_trader:schwab_cli -- build-equity-order \ + --action BUY \ + --symbol AAPL \ + --quantity 1 \ + --order-type MARKET +``` + +`place-equity-order` is also dry-run by default: + +```bash +bazel run //schwab_trader:schwab_cli -- place-equity-order \ + --account-hash "$SCHWAB_ACCOUNT_HASH" \ + --action SELL \ + --symbol AAPL \ + --quantity 1 \ + --order-type LIMIT \ + --price 250.00 +``` + +To actually submit an order, both safety flags are required: + +```bash +bazel run //schwab_trader:schwab_cli -- place-equity-order \ + --account-hash "$SCHWAB_ACCOUNT_HASH" \ + --action BUY \ + --symbol AAPL \ + --quantity 1 \ + --order-type MARKET \ + --live \ + --confirm-live-trade +``` + +Use live trading only after checking the generated JSON, account hash, symbol, quantity, order type, and Schwab API permissions. + +## Local sentiment dashboard + +Run the local dashboard: + +```bash +bazel run //schwab_trader:schwab_dashboard +``` + +Then open: + +```text +http://127.0.0.1:8765 +``` + +The dashboard is intentionally local-first and read/paper-trade oriented. It has no live-trade endpoint. It shows: + +- Schwab environment/token status without exposing token values +- risk settings such as profit target, stop loss, confidence threshold, and max paper-trade dollars +- manually added social evidence from Reddit/X/news/etc. +- deterministic sentiment signals and confidence +- paper trades with simple risk rejection +- audit events + +The dashboard stores state in: + +```text +~/.local/share/zenbu/schwab_trader/dashboard.db +``` + +Override it when testing: + +```bash +bazel run //schwab_trader:schwab_dashboard -- --db /tmp/schwab_dashboard.db +``` + +Social evidence can be added through the page or API: + +```bash +curl -X POST http://127.0.0.1:8765/api/evidence \ + -H 'Content-Type: application/json' \ + -d '{"source":"reddit","symbol":"AAPL","text":"$AAPL bullish strong growth","engagement":42}' +```
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schwab_trader/__init__.py Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,2 @@ +"""Schwab Trader API helpers for Zenbu.""" +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schwab_trader/dashboard.py Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,598 @@ +from __future__ import annotations + +import json +import os +import re +import sqlite3 +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from schwab_trader.schwab_client import DEFAULT_TOKEN_FILE, SchwabConfig, load_tokens + + +DEFAULT_DASHBOARD_DB = "~/.local/share/zenbu/schwab_trader/dashboard.db" + +DEFAULT_SETTINGS: dict[str, Any] = { + "profit_target_pct": 3.0, + "stop_loss_pct": 2.0, + "max_trade_dollars": 500.0, + "max_account_pct": 2.0, + "max_open_positions": 3, + "min_confidence": 0.60, + "min_evidence_count": 3, + "min_source_count": 2, + "cooldown_minutes": 60, + "market_hours_only": True, + "max_daily_loss_dollars": 250.0, + "day_trade_limit": 3, + "live_trading_enabled": False, + "require_manual_confirmation": True, + "llm_enabled": False, + "allowlist": [], + "blocklist": [], +} + +POSITIVE_WORDS = { + "beat", + "beats", + "bull", + "bullish", + "buy", + "calls", + "growth", + "hype", + "moon", + "mooning", + "profit", + "rally", + "strong", + "surge", + "up", + "winner", +} + +NEGATIVE_WORDS = { + "bear", + "bearish", + "crash", + "dump", + "fall", + "falling", + "fraud", + "lawsuit", + "loss", + "miss", + "puts", + "risk", + "sell", + "short", + "weak", +} + +SYMBOL_RE = re.compile(r"(?<![A-Z0-9])\$?([A-Z]{1,5})(?![A-Z0-9])") +COMMON_WORDS = { + "A", + "AI", + "AM", + "API", + "CEO", + "CFO", + "DD", + "ETF", + "GDP", + "IPO", + "IRS", + "LLM", + "PDT", + "SEC", + "USA", + "USD", +} + + +@dataclass(frozen=True) +class EvidenceInput: + source: str + text: str + symbol: str | None = None + url: str | None = None + engagement: float = 0.0 + raw: dict[str, Any] | None = None + + +class DashboardStore: + def __init__(self, db_path: Path | str | None = None) -> None: + if db_path is None: + db_path = os.environ.get("SCHWAB_DASHBOARD_DB", DEFAULT_DASHBOARD_DB) + self.db_path = Path(db_path).expanduser() + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._init_db() + + def get_status(self) -> dict[str, Any]: + token_file = Path(os.environ.get("SCHWAB_TOKEN_FILE", DEFAULT_TOKEN_FILE)).expanduser() + token_status: dict[str, Any] = { + "path": str(token_file), + "exists": token_file.exists(), + "access_token_present": False, + "refresh_token_present": False, + "saved_at": None, + "age_seconds": None, + } + if token_file.exists(): + try: + tokens = load_tokens(token_file) + saved_at = tokens.get("saved_at") + token_status.update( + { + "access_token_present": bool(tokens.get("access_token")), + "refresh_token_present": bool(tokens.get("refresh_token")), + "saved_at": saved_at, + "age_seconds": int(time.time()) - int(saved_at) if saved_at else None, + } + ) + except (OSError, ValueError, TypeError) as error: + token_status["error"] = str(error) + + env_status = { + "SCHWAB_APP_KEY": bool(os.environ.get("SCHWAB_APP_KEY")), + "SCHWAB_APP_SECRET": bool(os.environ.get("SCHWAB_APP_SECRET")), + "SCHWAB_REDIRECT_URI": bool(os.environ.get("SCHWAB_REDIRECT_URI")), + } + + return { + "service": "schwab-dashboard", + "database": str(self.db_path), + "env": env_status, + "tokens": token_status, + "live_trading_enabled": False, + "live_trading_note": "Dashboard has no live-trade endpoint; use CLI dry-run/manual confirmation flow.", + "counts": self.get_counts(), + } + + def get_counts(self) -> dict[str, int]: + with self._connect() as conn: + return { + "evidence": self._count(conn, "evidence"), + "signals": self._count(conn, "signals"), + "paper_trades": self._count(conn, "paper_trades"), + "audit_events": self._count(conn, "audit"), + } + + def get_settings(self) -> dict[str, Any]: + settings = dict(DEFAULT_SETTINGS) + with self._connect() as conn: + for row in conn.execute("SELECT key, value_json FROM settings"): + settings[row["key"]] = json.loads(row["value_json"]) + return settings + + def update_settings(self, updates: dict[str, Any]) -> dict[str, Any]: + allowed = set(DEFAULT_SETTINGS) + unknown = sorted(set(updates) - allowed) + if unknown: + raise ValueError("Unknown settings: " + ", ".join(unknown)) + + current = self.get_settings() + current.update(updates) + self._validate_settings(current) + + with self._connect() as conn: + for key, value in current.items(): + conn.execute( + """ + INSERT INTO settings(key, value_json) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json + """, + (key, json.dumps(value, sort_keys=True)), + ) + conn.commit() + self.add_audit("settings.updated", "Dashboard settings updated", updates) + return current + + def add_evidence(self, item: EvidenceInput) -> dict[str, Any]: + source = item.source.strip().lower() + text = item.text.strip() + if not source: + raise ValueError("source is required") + if not text: + raise ValueError("text is required") + + symbol = normalize_symbol(item.symbol) if item.symbol else extract_symbol(text) + if not symbol: + raise ValueError("symbol is required or must be detectable as a ticker in text") + + sentiment_score = score_sentiment(text) + created_at = int(time.time()) + + with self._connect() as conn: + cursor = conn.execute( + """ + INSERT INTO evidence( + source, symbol, url, text, engagement, sentiment_score, raw_json, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + source, + symbol, + item.url, + text, + float(item.engagement), + sentiment_score, + json.dumps(item.raw or {}, sort_keys=True), + created_at, + ), + ) + evidence_id = int(cursor.lastrowid) + conn.commit() + + signal = self.recompute_signal(symbol) + self.add_audit( + "evidence.added", + f"Added {source} evidence for {symbol}", + {"evidence_id": evidence_id, "symbol": symbol, "signal": signal}, + ) + return {"id": evidence_id, "symbol": symbol, "sentiment_score": sentiment_score, "signal": signal} + + def list_evidence(self, limit: int = 100) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT id, source, symbol, url, text, engagement, sentiment_score, created_at + FROM evidence + ORDER BY id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [dict(row) for row in rows] + + def recompute_signal(self, symbol: str) -> dict[str, Any]: + symbol = normalize_symbol(symbol) + settings = self.get_settings() + with self._connect() as conn: + rows = conn.execute( + """ + SELECT source, sentiment_score, engagement, created_at + FROM evidence + WHERE symbol = ? + ORDER BY id DESC + LIMIT 100 + """, + (symbol,), + ).fetchall() + + evidence_count = len(rows) + source_count = len({row["source"] for row in rows}) + weighted_total = 0.0 + weight_sum = 0.0 + for row in rows: + engagement_weight = min(5.0, 1.0 + max(0.0, float(row["engagement"])) / 100.0) + weighted_total += float(row["sentiment_score"]) * engagement_weight + weight_sum += engagement_weight + sentiment_score = weighted_total / weight_sum if weight_sum else 0.0 + confidence = compute_confidence(sentiment_score, evidence_count, source_count) + action = decide_signal_action(symbol, sentiment_score, confidence, evidence_count, source_count, settings) + summary = summarize_signal(symbol, sentiment_score, confidence, evidence_count, source_count, action) + updated_at = int(time.time()) + + conn.execute( + """ + INSERT INTO signals( + symbol, sentiment_score, confidence, evidence_count, source_count, + action, summary, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(symbol) DO UPDATE SET + sentiment_score = excluded.sentiment_score, + confidence = excluded.confidence, + evidence_count = excluded.evidence_count, + source_count = excluded.source_count, + action = excluded.action, + summary = excluded.summary, + updated_at = excluded.updated_at + """, + ( + symbol, + sentiment_score, + confidence, + evidence_count, + source_count, + action, + summary, + updated_at, + ), + ) + conn.commit() + + return { + "symbol": symbol, + "sentiment_score": round(sentiment_score, 4), + "confidence": round(confidence, 4), + "evidence_count": evidence_count, + "source_count": source_count, + "action": action, + "summary": summary, + "updated_at": updated_at, + } + + def list_signals(self) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT symbol, sentiment_score, confidence, evidence_count, source_count, + action, summary, updated_at + FROM signals + ORDER BY confidence DESC, updated_at DESC + """ + ).fetchall() + return [dict(row) for row in rows] + + def add_paper_trade(self, payload: dict[str, Any]) -> dict[str, Any]: + symbol = normalize_symbol(str(payload.get("symbol", ""))) + action = str(payload.get("action", "")).upper() + quantity = float(payload.get("quantity", 0)) + price = float(payload.get("price", 0)) + reason = str(payload.get("reason", "manual paper trade")).strip() + + if action not in {"BUY", "SELL"}: + raise ValueError("action must be BUY or SELL") + if not symbol: + raise ValueError("symbol is required") + if quantity <= 0: + raise ValueError("quantity must be greater than zero") + if price <= 0: + raise ValueError("price must be greater than zero") + + settings = self.get_settings() + notional = quantity * price + status = "accepted" + if notional > float(settings["max_trade_dollars"]): + status = "rejected_max_trade_dollars" + elif symbol in {normalize_symbol(s) for s in settings["blocklist"]}: + status = "rejected_blocklist" + + created_at = int(time.time()) + with self._connect() as conn: + cursor = conn.execute( + """ + INSERT INTO paper_trades(symbol, action, quantity, price, notional, status, reason, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (symbol, action, quantity, price, notional, status, reason, created_at), + ) + trade_id = int(cursor.lastrowid) + conn.commit() + + result = { + "id": trade_id, + "symbol": symbol, + "action": action, + "quantity": quantity, + "price": price, + "notional": notional, + "status": status, + "reason": reason, + "created_at": created_at, + } + self.add_audit("paper_trade.created", f"Paper trade {status}: {action} {quantity} {symbol}", result) + return result + + def list_paper_trades(self, limit: int = 100) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT id, symbol, action, quantity, price, notional, status, reason, created_at + FROM paper_trades + ORDER BY id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [dict(row) for row in rows] + + def add_audit(self, event_type: str, message: str, payload: dict[str, Any] | None = None) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO audit(event_type, message, payload_json, created_at) + VALUES (?, ?, ?, ?) + """, + (event_type, message, json.dumps(payload or {}, sort_keys=True), int(time.time())), + ) + conn.commit() + + def list_audit(self, limit: int = 100) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT id, event_type, message, payload_json, created_at + FROM audit + ORDER BY id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + events = [] + for row in rows: + event = dict(row) + event["payload"] = json.loads(event.pop("payload_json")) + events.append(event) + return events + + def _init_db(self) -> None: + with self._connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS evidence ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + symbol TEXT NOT NULL, + url TEXT, + text TEXT NOT NULL, + engagement REAL NOT NULL DEFAULT 0, + sentiment_score REAL NOT NULL, + raw_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_evidence_symbol_created + ON evidence(symbol, created_at); + + CREATE TABLE IF NOT EXISTS signals ( + symbol TEXT PRIMARY KEY, + sentiment_score REAL NOT NULL, + confidence REAL NOT NULL, + evidence_count INTEGER NOT NULL, + source_count INTEGER NOT NULL, + action TEXT NOT NULL, + summary TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS paper_trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + action TEXT NOT NULL, + quantity REAL NOT NULL, + price REAL NOT NULL, + notional REAL NOT NULL, + status TEXT NOT NULL, + reason TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + message TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + """ + ) + for key, value in DEFAULT_SETTINGS.items(): + conn.execute( + "INSERT OR IGNORE INTO settings(key, value_json) VALUES (?, ?)", + (key, json.dumps(value, sort_keys=True)), + ) + conn.commit() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + @staticmethod + def _count(conn: sqlite3.Connection, table: str) -> int: + return int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) + + @staticmethod + def _validate_settings(settings: dict[str, Any]) -> None: + positive_numbers = [ + "profit_target_pct", + "stop_loss_pct", + "max_trade_dollars", + "max_account_pct", + "max_daily_loss_dollars", + ] + for key in positive_numbers: + if float(settings[key]) <= 0: + raise ValueError(f"{key} must be greater than zero") + if not 0 <= float(settings["min_confidence"]) <= 1: + raise ValueError("min_confidence must be between 0 and 1") + if int(settings["min_evidence_count"]) < 1: + raise ValueError("min_evidence_count must be at least 1") + if int(settings["min_source_count"]) < 1: + raise ValueError("min_source_count must be at least 1") + if bool(settings["live_trading_enabled"]): + raise ValueError("live_trading_enabled cannot be enabled from the dashboard") + + +def normalize_symbol(value: str) -> str: + symbol = value.strip().upper().lstrip("$") + if not re.fullmatch(r"[A-Z]{1,5}", symbol): + raise ValueError("symbol must be 1-5 letters") + return symbol + + +def extract_symbol(text: str) -> str | None: + for match in SYMBOL_RE.finditer(text.upper()): + symbol = match.group(1) + if symbol not in COMMON_WORDS: + return symbol + return None + + +def score_sentiment(text: str) -> float: + words = re.findall(r"[a-zA-Z']+", text.lower()) + positive = sum(1 for word in words if word in POSITIVE_WORDS) + negative = sum(1 for word in words if word in NEGATIVE_WORDS) + total = positive + negative + if total == 0: + return 0.0 + return max(-1.0, min(1.0, (positive - negative) / total)) + + +def compute_confidence(sentiment_score: float, evidence_count: int, source_count: int) -> float: + evidence_component = min(0.35, evidence_count * 0.07) + source_component = min(0.25, source_count * 0.10) + sentiment_component = min(0.20, abs(sentiment_score) * 0.20) + return min(0.95, 0.20 + evidence_component + source_component + sentiment_component) + + +def decide_signal_action( + symbol: str, + sentiment_score: float, + confidence: float, + evidence_count: int, + source_count: int, + settings: dict[str, Any], +) -> str: + blocklist = {normalize_symbol(item) for item in settings.get("blocklist", [])} + allowlist = {normalize_symbol(item) for item in settings.get("allowlist", [])} + if symbol in blocklist: + return "NO_TRADE_BLOCKED" + if allowlist and symbol not in allowlist: + return "NO_TRADE_NOT_ALLOWLISTED" + if evidence_count < int(settings["min_evidence_count"]): + return "NO_TRADE_NEEDS_EVIDENCE" + if source_count < int(settings["min_source_count"]): + return "NO_TRADE_NEEDS_SOURCE_DIVERSITY" + if confidence < float(settings["min_confidence"]): + return "NO_TRADE_LOW_CONFIDENCE" + if sentiment_score >= 0.25: + return "CONSIDER_BUY" + if sentiment_score <= -0.25: + return "CONSIDER_SELL" + return "WATCH" + + +def summarize_signal( + symbol: str, + sentiment_score: float, + confidence: float, + evidence_count: int, + source_count: int, + action: str, +) -> str: + direction = "positive" if sentiment_score > 0 else "negative" if sentiment_score < 0 else "neutral" + return ( + f"{symbol} has {direction} social sentiment from {evidence_count} evidence item(s) " + f"across {source_count} source(s). Confidence is {confidence:.0%}. Action: {action}." + ) + + +def get_config_if_available() -> SchwabConfig | None: + try: + return SchwabConfig.from_env() + except Exception: + return None +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schwab_trader/dashboard_server.py Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,331 @@ +from __future__ import annotations + +import argparse +import json +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.parse import parse_qs, urlparse + +from schwab_trader.dashboard import DashboardStore, EvidenceInput, get_config_if_available +from schwab_trader.schwab_client import SchwabError, get_account, get_account_numbers, get_accounts, load_tokens + + +INDEX_HTML = """<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Schwab Sentiment Dashboard</title> + <style> + :root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; background: #0b1020; color: #eef2ff; } + body { margin: 0; } + header { padding: 24px; background: linear-gradient(135deg, #172554, #0f172a); border-bottom: 1px solid #334155; } + h1 { margin: 0 0 8px; font-size: 28px; } + main { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); padding: 16px; } + section { background: #111827; border: 1px solid #334155; border-radius: 14px; padding: 16px; box-shadow: 0 10px 25px #0004; } + h2 { margin-top: 0; font-size: 18px; } + label { display: block; margin: 10px 0 4px; color: #cbd5e1; } + input, textarea, select, button { width: 100%; box-sizing: border-box; border-radius: 8px; border: 1px solid #475569; background: #020617; color: #f8fafc; padding: 10px; } + button { margin-top: 12px; background: #2563eb; border: 0; font-weight: 700; cursor: pointer; } + button.secondary { background: #334155; } + pre { overflow: auto; white-space: pre-wrap; word-break: break-word; background: #020617; padding: 12px; border-radius: 8px; } + table { width: 100%; border-collapse: collapse; font-size: 13px; } + th, td { border-bottom: 1px solid #334155; padding: 8px; text-align: left; vertical-align: top; } + .ok { color: #86efac; } + .warn { color: #fbbf24; } + .bad { color: #fca5a5; } + .span2 { grid-column: 1 / -1; } + </style> +</head> +<body> + <header> + <h1>Schwab Sentiment Dashboard</h1> + <div>This is local-first and read/paper-trade focused. There is no live-trade endpoint in this dashboard.</div> + </header> + <main> + <section> + <h2>Status</h2> + <button onclick="loadAll()">Refresh</button> + <pre id="status">Loading...</pre> + </section> + + <section> + <h2>Settings</h2> + <label>Profit target %</label><input id="profit_target_pct" type="number" step="0.1"> + <label>Stop loss %</label><input id="stop_loss_pct" type="number" step="0.1"> + <label>Max dollars per paper trade</label><input id="max_trade_dollars" type="number" step="1"> + <label>Minimum confidence</label><input id="min_confidence" type="number" step="0.01" min="0" max="1"> + <button onclick="saveSettings()">Save Settings</button> + <pre id="settingsResult"></pre> + </section> + + <section> + <h2>Add Social Evidence</h2> + <label>Source</label><input id="source" placeholder="reddit"> + <label>Symbol</label><input id="symbol" placeholder="AAPL"> + <label>URL</label><input id="url" placeholder="https://..."> + <label>Engagement</label><input id="engagement" type="number" value="0"> + <label>Text</label><textarea id="text" rows="5" placeholder="$AAPL looks bullish..."></textarea> + <button onclick="addEvidence()">Add Evidence</button> + <pre id="evidenceResult"></pre> + </section> + + <section> + <h2>Paper Trade</h2> + <label>Action</label><select id="paperAction"><option>BUY</option><option>SELL</option></select> + <label>Symbol</label><input id="paperSymbol" placeholder="AAPL"> + <label>Quantity</label><input id="paperQuantity" type="number" step="0.01" value="1"> + <label>Price</label><input id="paperPrice" type="number" step="0.01"> + <label>Reason</label><input id="paperReason" placeholder="manual paper trade"> + <button onclick="addPaperTrade()">Create Paper Trade</button> + <pre id="paperResult"></pre> + </section> + + <section class="span2"> + <h2>Signals</h2> + <div id="signals"></div> + </section> + + <section> + <h2>Recent Evidence</h2> + <div id="evidence"></div> + </section> + + <section> + <h2>Paper Trades</h2> + <div id="paperTrades"></div> + </section> + + <section class="span2"> + <h2>Audit Log</h2> + <div id="audit"></div> + </section> + </main> + <script> + async function api(path, options = {}) { + const response = await fetch(path, { + headers: {'Content-Type': 'application/json'}, + ...options + }); + const body = await response.json(); + if (!response.ok) throw new Error(body.error || response.statusText); + return body; + } + + function json(id, value) { + document.getElementById(id).textContent = JSON.stringify(value, null, 2); + } + + function table(rows, cols) { + if (!rows.length) return '<p class="warn">No data yet.</p>'; + return '<table><thead><tr>' + cols.map(c => `<th>${c}</th>`).join('') + + '</tr></thead><tbody>' + rows.map(row => '<tr>' + cols.map(c => `<td>${row[c] ?? ''}</td>`).join('') + '</tr>').join('') + '</tbody></table>'; + } + + async function loadAll() { + const [status, settings, signals, evidence, paperTrades, audit] = await Promise.all([ + api('/api/status'), api('/api/settings'), api('/api/signals'), + api('/api/evidence'), api('/api/paper-trades'), api('/api/audit') + ]); + json('status', status); + for (const key of ['profit_target_pct', 'stop_loss_pct', 'max_trade_dollars', 'min_confidence']) { + document.getElementById(key).value = settings[key]; + } + document.getElementById('signals').innerHTML = table(signals, ['symbol', 'action', 'sentiment_score', 'confidence', 'evidence_count', 'source_count', 'summary']); + document.getElementById('evidence').innerHTML = table(evidence, ['id', 'source', 'symbol', 'sentiment_score', 'engagement', 'text']); + document.getElementById('paperTrades').innerHTML = table(paperTrades, ['id', 'symbol', 'action', 'quantity', 'price', 'notional', 'status', 'reason']); + document.getElementById('audit').innerHTML = table(audit, ['id', 'event_type', 'message', 'created_at']); + } + + async function saveSettings() { + try { + const body = {}; + for (const key of ['profit_target_pct', 'stop_loss_pct', 'max_trade_dollars', 'min_confidence']) { + body[key] = Number(document.getElementById(key).value); + } + json('settingsResult', await api('/api/settings', {method: 'POST', body: JSON.stringify(body)})); + await loadAll(); + } catch (error) { json('settingsResult', {error: error.message}); } + } + + async function addEvidence() { + try { + const body = { + source: document.getElementById('source').value, + symbol: document.getElementById('symbol').value, + url: document.getElementById('url').value, + engagement: Number(document.getElementById('engagement').value), + text: document.getElementById('text').value + }; + json('evidenceResult', await api('/api/evidence', {method: 'POST', body: JSON.stringify(body)})); + await loadAll(); + } catch (error) { json('evidenceResult', {error: error.message}); } + } + + async function addPaperTrade() { + try { + const body = { + action: document.getElementById('paperAction').value, + symbol: document.getElementById('paperSymbol').value, + quantity: Number(document.getElementById('paperQuantity').value), + price: Number(document.getElementById('paperPrice').value), + reason: document.getElementById('paperReason').value + }; + json('paperResult', await api('/api/paper-trades', {method: 'POST', body: JSON.stringify(body)})); + await loadAll(); + } catch (error) { json('paperResult', {error: error.message}); } + } + + loadAll(); + </script> +</body> +</html> +""" + + +def create_handler(store: DashboardStore) -> type[BaseHTTPRequestHandler]: + class DashboardHandler(BaseHTTPRequestHandler): + server_version = "SchwabDashboard/0.1" + + def do_GET(self) -> None: + try: + parsed = urlparse(self.path) + query = parse_qs(parsed.query) + if parsed.path == "/": + self._send_html(INDEX_HTML) + elif parsed.path == "/api/status": + self._send_json(store.get_status()) + elif parsed.path == "/api/settings": + self._send_json(store.get_settings()) + elif parsed.path == "/api/evidence": + self._send_json(store.list_evidence()) + elif parsed.path == "/api/signals": + self._send_json(store.list_signals()) + elif parsed.path == "/api/paper-trades": + self._send_json(store.list_paper_trades()) + elif parsed.path == "/api/audit": + self._send_json(store.list_audit()) + elif parsed.path == "/api/schwab/account-numbers": + self._send_json(_load_schwab_account_numbers()) + elif parsed.path == "/api/schwab/accounts": + fields = "positions" if query.get("positions") == ["1"] else None + self._send_json(_load_schwab_accounts(fields)) + elif parsed.path == "/api/schwab/account": + account_hash = query.get("account_hash", [""])[0] + fields = "positions" if query.get("positions") == ["1"] else None + self._send_json(_load_schwab_account(account_hash, fields)) + else: + self._send_error(HTTPStatus.NOT_FOUND, "Not found") + except (ValueError, SchwabError) as error: + self._send_error(HTTPStatus.BAD_REQUEST, str(error)) + + def do_POST(self) -> None: + try: + parsed = urlparse(self.path) + payload = self._read_json() + if parsed.path == "/api/settings": + self._send_json(store.update_settings(payload)) + elif parsed.path == "/api/evidence": + result = store.add_evidence( + EvidenceInput( + source=str(payload.get("source", "")), + symbol=str(payload["symbol"]) if payload.get("symbol") else None, + url=str(payload["url"]) if payload.get("url") else None, + text=str(payload.get("text", "")), + engagement=float(payload.get("engagement", 0)), + raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else None, + ) + ) + self._send_json(result, HTTPStatus.CREATED) + elif parsed.path == "/api/paper-trades": + self._send_json(store.add_paper_trade(payload), HTTPStatus.CREATED) + else: + self._send_error(HTTPStatus.NOT_FOUND, "Not found") + except (ValueError, KeyError, SchwabError) as error: + self._send_error(HTTPStatus.BAD_REQUEST, str(error)) + + def log_message(self, format: str, *args: Any) -> None: + print(f"[dashboard] {self.address_string()} - {format % args}") + + def _read_json(self) -> dict[str, Any]: + length = int(self.headers.get("Content-Length", "0")) + if length <= 0: + return {} + raw = self.rfile.read(length).decode("utf-8") + value = json.loads(raw) + if not isinstance(value, dict): + raise ValueError("JSON body must be an object") + return value + + def _send_html(self, body: str) -> None: + data = body.encode("utf-8") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _send_json(self, body: Any, status: HTTPStatus = HTTPStatus.OK) -> None: + data = json.dumps(body, indent=2, sort_keys=True).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _send_error(self, status: HTTPStatus, message: str) -> None: + self._send_json({"error": message}, status) + + return DashboardHandler + + +def _load_schwab_account_numbers() -> Any: + config, access_token = _load_schwab_access_token() + return get_account_numbers(access_token).body + + +def _load_schwab_accounts(fields: str | None) -> Any: + config, access_token = _load_schwab_access_token() + return get_accounts(access_token, fields).body + + +def _load_schwab_account(account_hash: str, fields: str | None) -> Any: + if not account_hash: + raise ValueError("account_hash is required") + config, access_token = _load_schwab_access_token() + return get_account(access_token, account_hash, fields).body + + +def _load_schwab_access_token() -> tuple[Any, str]: + config = get_config_if_available() + if config is None: + raise SchwabError("Schwab environment is not configured") + tokens = load_tokens(config.token_file) + access_token = tokens.get("access_token") + if not access_token: + raise SchwabError(f"No access_token in {config.token_file}") + return config, access_token + + +def run(host: str, port: int, db_path: str | None = None) -> None: + store = DashboardStore(db_path) + server = ThreadingHTTPServer((host, port), create_handler(store)) + print(f"Schwab dashboard listening on http://{host}:{port}") + print("Live trading is disabled in this dashboard.") + server.serve_forever() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run the local Schwab sentiment dashboard") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", default=8765, type=int) + parser.add_argument("--db", help="SQLite dashboard DB path") + args = parser.parse_args() + run(args.host, args.port, args.db) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schwab_trader/dashboard_test.py Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,61 @@ +import json +import tempfile +import threading +import unittest +import urllib.request +from http.server import ThreadingHTTPServer +from pathlib import Path + +from schwab_trader.dashboard import DashboardStore, EvidenceInput, score_sentiment +from schwab_trader.dashboard_server import create_handler + + +class DashboardStoreTest(unittest.TestCase): + def test_sentiment_scoring(self): + self.assertGreater(score_sentiment("$AAPL bullish strong growth"), 0) + self.assertLess(score_sentiment("$AAPL bearish weak sell"), 0) + + def test_evidence_recomputes_signal(self): + with tempfile.TemporaryDirectory() as temp_dir: + store = DashboardStore(Path(temp_dir) / "dashboard.db") + store.update_settings({"min_evidence_count": 2, "min_source_count": 2, "min_confidence": 0.3}) + + store.add_evidence(EvidenceInput(source="reddit", symbol="AAPL", text="$AAPL bullish strong", engagement=20)) + result = store.add_evidence(EvidenceInput(source="x", symbol="AAPL", text="$AAPL growth surge", engagement=50)) + + self.assertEqual("AAPL", result["symbol"]) + signals = store.list_signals() + self.assertEqual(1, len(signals)) + self.assertEqual("CONSIDER_BUY", signals[0]["action"]) + + def test_paper_trade_rejects_above_max_trade_dollars(self): + with tempfile.TemporaryDirectory() as temp_dir: + store = DashboardStore(Path(temp_dir) / "dashboard.db") + store.update_settings({"max_trade_dollars": 100}) + + trade = store.add_paper_trade( + {"symbol": "AAPL", "action": "BUY", "quantity": 2, "price": 75, "reason": "test"} + ) + + self.assertEqual("rejected_max_trade_dollars", trade["status"]) + + def test_dashboard_http_status(self): + with tempfile.TemporaryDirectory() as temp_dir: + store = DashboardStore(Path(temp_dir) / "dashboard.db") + server = ThreadingHTTPServer(("127.0.0.1", 0), create_handler(store)) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_port}/api/status" + with urllib.request.urlopen(url, timeout=5) as response: + body = json.loads(response.read().decode("utf-8")) + self.assertEqual("schwab-dashboard", body["service"]) + self.assertFalse(body["live_trading_enabled"]) + finally: + server.shutdown() + thread.join(timeout=5) + + +if __name__ == "__main__": + unittest.main() +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schwab_trader/env.example Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,10 @@ +# Copy this to your shell profile or an untracked local env file. +# Never commit real Schwab credentials or OAuth tokens. + +export SCHWAB_APP_KEY="your-schwab-app-key" +export SCHWAB_APP_SECRET="your-schwab-app-secret" +export SCHWAB_REDIRECT_URI="https://127.0.0.1" + +# Optional. Defaults to ~/.config/zenbu/schwab_tokens.json. +export SCHWAB_TOKEN_FILE="$HOME/.config/zenbu/schwab_tokens.json" +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schwab_trader/schwab_cli.py Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,171 @@ +from __future__ import annotations + +import argparse +import json +import sys + +from schwab_trader.schwab_client import ( + SchwabConfig, + SchwabError, + build_authorization_url, + build_equity_order, + exchange_code_for_tokens, + get_account, + get_account_numbers, + get_accounts, + load_tokens, + place_order, + refresh_tokens, + save_tokens, +) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Safe Schwab Trader API helper. This tool never chooses trades for you.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + auth_url = subparsers.add_parser("auth-url", help="Print the Schwab OAuth authorization URL") + auth_url.add_argument("--state", help="Optional OAuth state value") + + token = subparsers.add_parser("token", help="Exchange an authorization code/callback URL for tokens") + token.add_argument("--code", required=True, help="Authorization code or full callback URL") + + subparsers.add_parser("refresh", help="Refresh and save tokens") + + subparsers.add_parser("account-numbers", help="Print Schwab account-number/account-hash mapping") + + accounts = subparsers.add_parser("accounts", help="Print account data") + accounts.add_argument("--positions", action="store_true", help="Include positions if API permissions allow it") + + account = subparsers.add_parser("account", help="Print one account by account hash") + account.add_argument("--account-hash", required=True) + account.add_argument("--positions", action="store_true", help="Include positions if API permissions allow it") + + build_order = subparsers.add_parser("build-equity-order", help="Build and print a stock order JSON payload") + _add_order_args(build_order) + + place_equity = subparsers.add_parser( + "place-equity-order", + help="Place a user-specified stock order; defaults to dry-run output only", + ) + _add_order_args(place_equity) + place_equity.add_argument("--account-hash", required=True) + place_equity.add_argument("--live", action="store_true", help="Actually submit the order to Schwab") + place_equity.add_argument( + "--confirm-live-trade", + action="store_true", + help="Required with --live to reduce accidental orders", + ) + + args = parser.parse_args(argv) + + try: + if args.command == "auth-url": + config = SchwabConfig.from_env() + print(build_authorization_url(config.app_key, config.redirect_uri, args.state)) + return 0 + + if args.command == "token": + config = SchwabConfig.from_env() + tokens = exchange_code_for_tokens(config, args.code) + save_tokens(config.token_file, tokens) + print(f"Saved tokens to {config.token_file}") + return 0 + + if args.command == "refresh": + config = SchwabConfig.from_env() + tokens = refresh_tokens(config) + save_tokens(config.token_file, tokens) + print(f"Refreshed tokens in {config.token_file}") + return 0 + + if args.command == "account-numbers": + config = SchwabConfig.from_env() + access_token = _load_access_token(config) + _print_json(get_account_numbers(access_token).body) + return 0 + + if args.command == "accounts": + config = SchwabConfig.from_env() + access_token = _load_access_token(config) + fields = "positions" if args.positions else None + _print_json(get_accounts(access_token, fields).body) + return 0 + + if args.command == "account": + config = SchwabConfig.from_env() + access_token = _load_access_token(config) + fields = "positions" if args.positions else None + _print_json(get_account(access_token, args.account_hash, fields).body) + return 0 + + if args.command == "build-equity-order": + order = _build_order_from_args(args) + _print_json(order) + return 0 + + if args.command == "place-equity-order": + order = _build_order_from_args(args) + if not args.live: + print("DRY RUN: order was not sent. Add --live --confirm-live-trade to submit.") + _print_json(order) + return 0 + if not args.confirm_live_trade: + raise SchwabError("--live requires --confirm-live-trade") + + config = SchwabConfig.from_env() + access_token = _load_access_token(config) + response = place_order(access_token, args.account_hash, order) + print(f"Schwab order response status: {response.status}") + location = response.headers.get("Location") + if location: + print(f"Order location: {location}") + if response.body is not None: + _print_json(response.body) + return 0 + + raise SchwabError(f"Unknown command: {args.command}") + except SchwabError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + +def _add_order_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--action", required=True, choices=["BUY", "SELL", "buy", "sell"]) + parser.add_argument("--symbol", required=True) + parser.add_argument("--quantity", required=True, type=float) + parser.add_argument("--order-type", default="MARKET", choices=["MARKET", "LIMIT", "market", "limit"]) + parser.add_argument("--price", type=float, help="Required for LIMIT orders; invalid for MARKET orders") + parser.add_argument("--duration", default="DAY") + parser.add_argument("--session", default="NORMAL") + + +def _build_order_from_args(args: argparse.Namespace) -> dict: + return build_equity_order( + action=args.action, + symbol=args.symbol, + quantity=args.quantity, + order_type=args.order_type, + price=args.price, + duration=args.duration, + session=args.session, + ) + + +def _load_access_token(config: SchwabConfig) -> str: + tokens = load_tokens(config.token_file) + access_token = tokens.get("access_token") + if not access_token: + raise SchwabError(f"No access_token in {config.token_file}") + return access_token + + +def _print_json(value: object) -> None: + print(json.dumps(value, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + raise SystemExit(main()) +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schwab_trader/schwab_client.py Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,275 @@ +from __future__ import annotations + +import base64 +import json +import os +import stat +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +AUTH_URL = "https://api.schwabapi.com/v1/oauth/authorize" +TOKEN_URL = "https://api.schwabapi.com/v1/oauth/token" +TRADER_BASE_URL = "https://api.schwabapi.com/trader/v1" +DEFAULT_TOKEN_FILE = "~/.config/zenbu/schwab_tokens.json" + + +class SchwabError(RuntimeError): + pass + + +@dataclass(frozen=True) +class SchwabConfig: + app_key: str + app_secret: str + redirect_uri: str + token_file: Path + + @classmethod + def from_env(cls) -> "SchwabConfig": + app_key = os.environ.get("SCHWAB_APP_KEY", "").strip() + app_secret = os.environ.get("SCHWAB_APP_SECRET", "").strip() + redirect_uri = os.environ.get("SCHWAB_REDIRECT_URI", "").strip() + token_file = Path(os.environ.get("SCHWAB_TOKEN_FILE", DEFAULT_TOKEN_FILE)).expanduser() + + missing = [ + name + for name, value in ( + ("SCHWAB_APP_KEY", app_key), + ("SCHWAB_APP_SECRET", app_secret), + ("SCHWAB_REDIRECT_URI", redirect_uri), + ) + if not value + ] + if missing: + raise SchwabError("Missing required environment variables: " + ", ".join(missing)) + + return cls( + app_key=app_key, + app_secret=app_secret, + redirect_uri=redirect_uri, + token_file=token_file, + ) + + +@dataclass(frozen=True) +class ApiResponse: + status: int + headers: dict[str, str] + body: Any + raw_body: str + + +def build_authorization_url(app_key: str, redirect_uri: str, state: str | None = None) -> str: + params = { + "response_type": "code", + "client_id": app_key, + "redirect_uri": redirect_uri, + } + if state: + params["state"] = state + return AUTH_URL + "?" + urllib.parse.urlencode(params) + + +def extract_authorization_code(code_or_url: str) -> str: + value = code_or_url.strip() + if not value: + raise SchwabError("Authorization code is empty") + + parsed = urllib.parse.urlparse(value) + if parsed.scheme and parsed.netloc: + query = urllib.parse.parse_qs(parsed.query) + codes = query.get("code") + if not codes or not codes[0]: + raise SchwabError("No code= parameter found in callback URL") + return codes[0] + + return urllib.parse.unquote(value) + + +def exchange_code_for_tokens(config: SchwabConfig, code_or_url: str) -> dict[str, Any]: + code = extract_authorization_code(code_or_url) + return _token_request( + config, + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": config.redirect_uri, + }, + ) + + +def refresh_tokens(config: SchwabConfig, refresh_token: str | None = None) -> dict[str, Any]: + token_value = refresh_token + if token_value is None: + existing = load_tokens(config.token_file) + token_value = existing.get("refresh_token") + if not token_value: + raise SchwabError("No refresh token available") + + return _token_request( + config, + { + "grant_type": "refresh_token", + "refresh_token": token_value, + }, + ) + + +def save_tokens(path: Path, tokens: dict[str, Any]) -> None: + payload = dict(tokens) + payload["saved_at"] = int(time.time()) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + path.chmod(stat.S_IRUSR | stat.S_IWUSR) + + +def load_tokens(path: Path) -> dict[str, Any]: + if not path.exists(): + raise SchwabError(f"Token file does not exist: {path}") + return json.loads(path.read_text(encoding="utf-8")) + + +def get_account_numbers(access_token: str) -> ApiResponse: + return _api_request("GET", "/accounts/accountNumbers", access_token) + + +def get_accounts(access_token: str, fields: str | None = None) -> ApiResponse: + query = "" + if fields: + query = "?" + urllib.parse.urlencode({"fields": fields}) + return _api_request("GET", "/accounts" + query, access_token) + + +def get_account(access_token: str, account_hash: str, fields: str | None = None) -> ApiResponse: + query = "" + if fields: + query = "?" + urllib.parse.urlencode({"fields": fields}) + return _api_request("GET", f"/accounts/{urllib.parse.quote(account_hash)}{query}", access_token) + + +def build_equity_order( + action: str, + symbol: str, + quantity: float, + order_type: str = "MARKET", + price: float | None = None, + duration: str = "DAY", + session: str = "NORMAL", +) -> dict[str, Any]: + normalized_action = action.upper() + normalized_symbol = symbol.upper() + normalized_order_type = order_type.upper() + normalized_duration = duration.upper() + normalized_session = session.upper() + + if normalized_action not in {"BUY", "SELL"}: + raise SchwabError("action must be BUY or SELL") + if not normalized_symbol: + raise SchwabError("symbol is required") + if quantity <= 0: + raise SchwabError("quantity must be greater than zero") + if normalized_order_type not in {"MARKET", "LIMIT"}: + raise SchwabError("order_type must be MARKET or LIMIT") + if normalized_order_type == "LIMIT" and price is None: + raise SchwabError("LIMIT orders require --price") + if normalized_order_type == "MARKET" and price is not None: + raise SchwabError("MARKET orders cannot include --price") + + order: dict[str, Any] = { + "orderType": normalized_order_type, + "session": normalized_session, + "duration": normalized_duration, + "orderStrategyType": "SINGLE", + "orderLegCollection": [ + { + "instruction": normalized_action, + "quantity": quantity, + "instrument": { + "symbol": normalized_symbol, + "assetType": "EQUITY", + }, + } + ], + } + if price is not None: + order["price"] = f"{price:.2f}" + return order + + +def place_order(access_token: str, account_hash: str, order: dict[str, Any]) -> ApiResponse: + path = f"/accounts/{urllib.parse.quote(account_hash)}/orders" + return _api_request("POST", path, access_token, order) + + +def _token_request(config: SchwabConfig, form: dict[str, str]) -> dict[str, Any]: + credentials = f"{config.app_key}:{config.app_secret}".encode("utf-8") + headers = { + "Authorization": "Basic " + base64.b64encode(credentials).decode("ascii"), + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + } + data = urllib.parse.urlencode(form).encode("utf-8") + request = urllib.request.Request(TOKEN_URL, data=data, headers=headers, method="POST") + response = _open_request(request) + if not isinstance(response.body, dict): + raise SchwabError("Token endpoint did not return a JSON object") + return response.body + + +def _api_request( + method: str, + path: str, + access_token: str, + body: dict[str, Any] | None = None, +) -> ApiResponse: + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + } + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + + request = urllib.request.Request( + TRADER_BASE_URL + path, + data=data, + headers=headers, + method=method, + ) + return _open_request(request) + + +def _open_request(request: urllib.request.Request) -> ApiResponse: + try: + with urllib.request.urlopen(request, timeout=30) as response: + raw = response.read().decode("utf-8") + return ApiResponse( + status=response.status, + headers=dict(response.headers.items()), + body=_parse_json_or_text(raw), + raw_body=raw, + ) + except urllib.error.HTTPError as error: + raw = error.read().decode("utf-8", errors="replace") + raise SchwabError( + f"Schwab API request failed with HTTP {error.code}: {raw or error.reason}" + ) from error + except urllib.error.URLError as error: + raise SchwabError(f"Schwab API request failed: {error.reason}") from error + + +def _parse_json_or_text(raw: str) -> Any: + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return raw +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schwab_trader/schwab_client_test.py Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,67 @@ +import os +import tempfile +import unittest +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from schwab_trader.schwab_client import ( + SchwabConfig, + SchwabError, + build_authorization_url, + build_equity_order, + extract_authorization_code, + save_tokens, +) + + +class SchwabClientTest(unittest.TestCase): + def test_authorization_url_contains_oauth_params(self): + url = build_authorization_url("app-key", "https://127.0.0.1/callback", "state-1") + parsed = urlparse(url) + params = parse_qs(parsed.query) + + self.assertEqual("https", parsed.scheme) + self.assertEqual("api.schwabapi.com", parsed.netloc) + self.assertEqual(["code"], params["response_type"]) + self.assertEqual(["app-key"], params["client_id"]) + self.assertEqual(["https://127.0.0.1/callback"], params["redirect_uri"]) + self.assertEqual(["state-1"], params["state"]) + + def test_extract_authorization_code_from_callback_url(self): + code = extract_authorization_code("https://127.0.0.1/callback?code=abc%40123&state=x") + self.assertEqual("abc@123", code) + + def test_build_market_equity_order(self): + order = build_equity_order("buy", "aapl", 1) + + self.assertEqual("MARKET", order["orderType"]) + self.assertEqual("BUY", order["orderLegCollection"][0]["instruction"]) + self.assertEqual("AAPL", order["orderLegCollection"][0]["instrument"]["symbol"]) + self.assertNotIn("price", order) + + def test_limit_order_requires_price(self): + with self.assertRaises(SchwabError): + build_equity_order("SELL", "MSFT", 2, order_type="LIMIT") + + def test_save_tokens_uses_owner_only_permissions(self): + with tempfile.TemporaryDirectory() as temp_dir: + token_file = Path(temp_dir) / "tokens.json" + save_tokens(token_file, {"access_token": "x"}) + + self.assertEqual(0o600, token_file.stat().st_mode & 0o777) + + def test_config_from_env_requires_credentials(self): + old_env = os.environ.copy() + try: + for key in ("SCHWAB_APP_KEY", "SCHWAB_APP_SECRET", "SCHWAB_REDIRECT_URI"): + os.environ.pop(key, None) + with self.assertRaises(SchwabError): + SchwabConfig.from_env() + finally: + os.environ.clear() + os.environ.update(old_env) + + +if __name__ == "__main__": + unittest.main() +
--- a/seobeo/s_http_client.c Sat Feb 28 21:04:43 2026 -0800 +++ b/seobeo/s_http_client.c Sun Aug 02 16:44:06 2026 -0700 @@ -1,5 +1,46 @@ #include "seobeo/seobeo.h" #include <ctype.h> +#include <time.h> + +static int64_t Seobeo_Client_Monotonic_Milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static boolean Seobeo_Client_Read_Timed_Out(int32 timeout_ms, int64_t last_progress_ms) +{ + return timeout_ms > 0 && + Seobeo_Client_Monotonic_Milliseconds() - last_progress_ms >= timeout_ms; +} + +static size_t Seobeo_Client_Find_Header_Length(const uint8 *buffer, size_t length) +{ + if (!buffer || length < 4) + return 0; + for (size_t i = 0; i + 3 < length; i++) + { + if (buffer[i] == '\r' && buffer[i + 1] == '\n' && + buffer[i + 2] == '\r' && buffer[i + 3] == '\n') + return i + 4; + } + return 0; +} + +static const char *Seobeo_Client_Header_Value( + Seobeo_Request_Entry *headers, + const char *name) +{ + if (!headers || !name) + return NULL; + for (size_t i = 0; i < Dowa_Array_Length(headers); i++) + { + if (headers[i].key && strcasecmp(headers[i].key, name) == 0) + return headers[i].value; + } + return NULL; +} static void Seobeo_Client_Parse_Url(const char *url, char **p_host, char **p_port, char **p_path, boolean *p_use_tls, Dowa_Arena *p_arena) @@ -87,6 +128,7 @@ p_req->follow_redirects = FALSE; p_req->max_redirects = 10; + p_req->timeout_ms = 0; return p_req; } @@ -149,6 +191,13 @@ p_req->max_redirects = max_redirects > 0 ? max_redirects : 10; } +void Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms) +{ + if (!p_req) + return; + p_req->timeout_ms = timeout_ms > 0 ? timeout_ms : 0; +} + void Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path) { if (!p_req || !path) @@ -223,7 +272,10 @@ return offset; } -static Seobeo_Client_Response *Seobeo_Client_Parse_Response(Seobeo_Handle *p_handle, const char *download_path) +static Seobeo_Client_Response *Seobeo_Client_Parse_Response( + Seobeo_Handle *p_handle, + const char *download_path, + int32 timeout_ms) { Seobeo_Client_Response *p_resp = malloc(sizeof(Seobeo_Client_Response)); if (!p_resp) @@ -238,27 +290,49 @@ return NULL; } + int64_t last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); while (TRUE) { int r = Seobeo_Handle_Read(p_handle); - if (r < 0) - return p_resp; if (r == -2) break; + if (r < 0) + { + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + if (r > 0) + last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); - if (p_handle->read_buffer_len >= 4 && strstr((char*)p_handle->read_buffer, "\r\n\r\n") != NULL) + if (Seobeo_Client_Find_Header_Length( + p_handle->read_buffer, p_handle->read_buffer_len) > 0) break; if (r == 0) + { + if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms)) + { + Seobeo_Log(SEOBEO_ERROR, "HTTP response header timed out\n"); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + usleep(1000); continue; + } } - char *buf = (char*)p_handle->read_buffer; - char *hdr_end = strstr(buf, "\r\n\r\n"); - if (!hdr_end) - return p_resp; + size_t hdr_len = Seobeo_Client_Find_Header_Length( + p_handle->read_buffer, p_handle->read_buffer_len); + if (hdr_len == 0) + { + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } - size_t hdr_len = hdr_end - buf + 4; + char *buf = Dowa_Arena_Allocate(p_resp->p_arena, hdr_len + 1); + memcpy(buf, p_handle->read_buffer, hdr_len); + buf[hdr_len] = '\0'; + char *hdr_end = buf + hdr_len - 4; char version[16]; int status_code; @@ -328,12 +402,12 @@ Seobeo_Handle_Consume(p_handle, (uint32)hdr_len); - void *p_cl_kv = Dowa_HashMap_Get_Ptr(p_resp->headers, "Content-Length"); size_t body_len = 0; - if (p_cl_kv) + const char *content_length = Seobeo_Client_Header_Value( + p_resp->headers, "Content-Length"); + if (content_length) { - const char *content_length_str = ((Seobeo_Request_Entry*)p_cl_kv)->value; - body_len = atoi(content_length_str); + body_len = (size_t)strtoull(content_length, NULL, 10); } FILE *p_file = NULL; @@ -366,15 +440,32 @@ total_read += to_copy; Seobeo_Handle_Consume(p_handle, (uint32)to_copy); + last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); } if (total_read < body_len) { int r = Seobeo_Handle_Read(p_handle); - if (r < 0 || r == -2) - break; + if (r == -2 || r < 0) + { + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + if (r > 0) + last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); if (r == 0) + { + if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms)) + { + Seobeo_Log(SEOBEO_ERROR, "HTTP response body timed out\n"); + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + usleep(1000); continue; + } } } @@ -397,9 +488,12 @@ while (1) { - int n = Seobeo_Handle_Read(p_handle); + int n = p_handle->read_buffer_len > 0 + ? (int)p_handle->read_buffer_len + : Seobeo_Handle_Read(p_handle); if (n > 0) { + last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); if (download_path) { fwrite(p_handle->read_buffer, 1, p_handle->read_buffer_len, p_file); @@ -420,9 +514,23 @@ else if (n == -2) break; else if (n == 0) + { + if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms)) + { + Seobeo_Log(SEOBEO_ERROR, "HTTP response body timed out\n"); + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + usleep(1000); continue; + } else - break; + { + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } } if (!download_path) @@ -468,7 +576,8 @@ return NULL; } - Seobeo_Client_Response *p_resp = Seobeo_Client_Parse_Response(p_handle, p_req->download_path); + Seobeo_Client_Response *p_resp = + Seobeo_Client_Parse_Response(p_handle, p_req->download_path, p_req->timeout_ms); Seobeo_Handle_Destroy(p_handle);
--- a/seobeo/s_network.c Sat Feb 28 21:04:43 2026 -0800 +++ b/seobeo/s_network.c Sun Aug 02 16:44:06 2026 -0700 @@ -1,8 +1,37 @@ #include "seobeo/seobeo.h" +static pthread_once_t g_sigpipe_once = PTHREAD_ONCE_INIT; + +static void Seobeo_Process_Ignore_Sigpipe(void) +{ + signal(SIGPIPE, SIG_IGN); +} + +static void Seobeo_Socket_Disable_Sigpipe(int socket_fd) +{ +#ifdef SO_NOSIGPIPE + int enabled = 1; + setsockopt(socket_fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)); +#else + (void)socket_fd; +#endif +} + +static ssize_t Seobeo_Socket_Write( + int socket_fd, + const void *buffer, + size_t length) +{ +#ifdef MSG_NOSIGNAL + return send(socket_fd, buffer, length, MSG_NOSIGNAL); +#else + return send(socket_fd, buffer, length, 0); +#endif +} Seobeo_Handle *Seobeo_Stream_Handle_Server_Create(const char *host, const char* port) { + pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe); Seobeo_Handle *p_handle; struct addrinfo hints, *server_infos, *free_server_info; int32 socket_fd, yes = 1; // Need this for setsockopt @@ -26,6 +55,7 @@ if((socket_fd = socket(free_server_info->ai_family, free_server_info->ai_socktype, free_server_info->ai_protocol)) == -1) { perror("socket"); continue; } + Seobeo_Socket_Disable_Sigpipe(socket_fd); if (setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) == -1) { perror("setsockopt SO_REUSEADDR"); continue; } @@ -81,6 +111,7 @@ Seobeo_Handle *Seobeo_Stream_Handle_Client_Create(const char *host, const char* port, boolean use_tls) { + pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe); Seobeo_Handle *p_handle; p_handle = malloc(sizeof(*p_handle)); @@ -98,6 +129,7 @@ if((socket_fd = socket(server_infos->ai_family, server_infos->ai_socktype, server_infos->ai_protocol)) == -1) { perror("socket"); return NULL; } + Seobeo_Socket_Disable_Sigpipe(socket_fd); if (connect(socket_fd, server_infos->ai_addr, server_infos->ai_addrlen) != 0) { perror("connect"); return NULL; } @@ -154,6 +186,7 @@ Seobeo_Get_IP4_Or_IP6((struct sockaddr *)&addr), client_inet_addr, sizeof client_inet_addr); if (client_fd == -1) return NULL; + Seobeo_Socket_Disable_Sigpipe(client_fd); // Set non blocking... int flags = fcntl(client_fd, F_GETFL, 0); @@ -236,7 +269,7 @@ }else { Seobeo_Log(SEOBEO_DEBUG, "Flushing socket: %d\n", p_handle->socket); - ssize_t n = write( + ssize_t n = Seobeo_Socket_Write( p_handle->socket, p_handle->write_buffer + sent, total - sent @@ -275,9 +308,10 @@ } else { - n = write(p_handle->socket, - data + offset, - data_size - offset); + n = Seobeo_Socket_Write( + p_handle->socket, + data + offset, + data_size - offset); } if (n==0)
--- a/seobeo/s_web.c Sat Feb 28 21:04:43 2026 -0800 +++ b/seobeo/s_web.c Sun Aug 02 16:44:06 2026 -0700 @@ -51,6 +51,7 @@ { case HTTP_OK: status_text = "OK"; break; case HTTP_CREATED: status_text = "Created"; break; + case HTTP_NO_CONTENT: status_text = "No Content"; break; case HTTP_MOVED_PERMANENTLY: status_text = "Moved Permanently"; break; case HTTP_FOUND: status_text = "Found"; break; case HTTP_BAD_REQUEST: status_text = "Bad Request"; break; @@ -58,11 +59,13 @@ case HTTP_FORBIDDEN: status_text = "Forbidden"; break; case HTTP_NOT_FOUND: status_text = "Not Found"; break; case HTTP_INTERNAL_ERROR: status_text = "Internal Server Error"; break; + case 502: status_text = "Bad Gateway"; break; + case 504: status_text = "Gateway Timeout"; break; default: status_text = "Unknown"; break; } - sprintf( - buffer, + snprintf( + (char*)buffer, 1024, "HTTP/1.1 %d %s\r\n" "Content-Type: %s\r\n" "Content-Length: %d\r\n" @@ -774,6 +777,11 @@ Seobeo_Router_Send_Response_KeepAlive(p_handle, p_response_map, p_arena, FALSE); } +static boolean Seobeo_Response_Header_Value_Is_Safe(const char *value) +{ + return value && strchr(value, '\r') == NULL && strchr(value, '\n') == NULL; +} + void Seobeo_Router_Send_Response_KeepAlive( Seobeo_Handle *p_handle, Seobeo_Request_Entry *p_response_map, @@ -819,26 +827,63 @@ else body_length = strlen(body); - char *header = Dowa_Arena_Allocate(p_arena, 4096); - Seobeo_Web_Header_Generate_KeepAlive(header, status, content_type, body_length, keep_alive); + size_t header_capacity = 1024; for (int i = 0; i < Dowa_Array_Length(p_response_map); i++) { + const char *key = p_response_map[i].key; + const char *value = p_response_map[i].value; if ( - strstr(p_response_map[i].key, "status") || - strstr(p_response_map[i].key, "body") || - strstr(p_response_map[i].key, "content-type") || - strstr(p_response_map[i].key, "content-length") + strcasecmp(key, "status") == 0 || + strcasecmp(key, "body") == 0 || + strcasecmp(key, "content-type") == 0 || + strcasecmp(key, "content-length") == 0 + ) + continue; + if (Seobeo_Response_Header_Value_Is_Safe(key) && + Seobeo_Response_Header_Value_Is_Safe(value)) + header_capacity += strlen(key) + strlen(value) + 4; + } + + char *header = Dowa_Arena_Allocate(p_arena, header_capacity); + Seobeo_Web_Header_Generate_KeepAlive(header, status, content_type, body_length, keep_alive); + size_t header_length = strlen(header); + if (header_length < 2) + return; + header_length -= 2; + + for (int i = 0; i < Dowa_Array_Length(p_response_map); i++) + { + const char *key = p_response_map[i].key; + const char *value = p_response_map[i].value; + if ( + strcasecmp(key, "status") == 0 || + strcasecmp(key, "body") == 0 || + strcasecmp(key, "content-type") == 0 || + strcasecmp(key, "content-length") == 0 ) continue; - int32 current_header_len = strlen(header); - char *temp = malloc(sizeof(char) * 1024); - sprintf(temp, "%s: %s\r\n\r\n", p_response_map[i].key, p_response_map[i].value); - memcpy(&header[current_header_len - 2 /* \r\n */], temp, strlen(temp)); - free(temp); + if (!Seobeo_Response_Header_Value_Is_Safe(key) || + !Seobeo_Response_Header_Value_Is_Safe(value)) + { + Seobeo_Log(SEOBEO_WARNING, "Skipping unsafe response header\n"); + continue; + } + + int written = snprintf( + header + header_length, + header_capacity - header_length, + "%s: %s\r\n", + key, + value); + if (written < 0 || (size_t)written >= header_capacity - header_length) + { + Seobeo_Log(SEOBEO_ERROR, "Response header exceeded allocated capacity\n"); + return; + } + header_length += (size_t)written; } - - printf("hEADER %s\n", header); + memcpy(header + header_length, "\r\n", 3); Seobeo_Handle_Queue(p_handle, (uint8_t*)header, strlen(header)); Seobeo_Handle_Queue(p_handle, (uint8_t*)body, body_length);
--- a/seobeo/seobeo.h Sat Feb 28 21:04:43 2026 -0800 +++ b/seobeo/seobeo.h Sun Aug 02 16:44:06 2026 -0700 @@ -30,6 +30,7 @@ // HTTP STATUS CODE #define HTTP_OK 200 #define HTTP_CREATED 201 +#define HTTP_NO_CONTENT 204 #define HTTP_MOVED_PERMANENTLY 301 #define HTTP_FOUND 302 #define HTTP_BAD_REQUEST 400 @@ -82,6 +83,8 @@ extern void Seobeo_Client_Request_Set_Body(Seobeo_Client_Request *p_req, const char *body, size_t length); /* Enable/disable following redirects with max redirect count. Default is FALSE with 10 max. */ extern void Seobeo_Client_Request_Set_Follow_Redirects(Seobeo_Client_Request *p_req, boolean follow, int32 max_redirects); +/* Set the maximum idle time for HTTP response reads. Zero disables the timeout. */ +extern void Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms); /* Set download path to save response body to file instead of memory. */ extern void Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path); /* Execute the HTTP request and return response. */
--- a/seobeo/seobeo_internal.h Sat Feb 28 21:04:43 2026 -0800 +++ b/seobeo/seobeo_internal.h Sun Aug 02 16:44:06 2026 -0700 @@ -131,6 +131,7 @@ boolean follow_redirects; int32 max_redirects; + int32 timeout_ms; char *download_path; @@ -158,6 +159,7 @@ extern void Seobeo_Client_Request_Add_Header_Array(Seobeo_Client_Request *p_req, const char *header); extern void Seobeo_Client_Request_Set_Body(Seobeo_Client_Request *p_req, const char *body, size_t length); extern void Seobeo_Client_Request_Set_Follow_Redirects(Seobeo_Client_Request *p_req, boolean follow, int32 max_redirects); +extern void Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms); extern void Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path); extern Seobeo_Client_Response *Seobeo_Client_Request_Execute(Seobeo_Client_Request *p_req); extern void Seobeo_Client_Request_Destroy(Seobeo_Client_Request *p_req);
--- a/seobeo/tests/BUILD Sat Feb 28 21:04:43 2026 -0800 +++ b/seobeo/tests/BUILD Sun Aug 02 16:44:06 2026 -0700 @@ -28,3 +28,48 @@ args = ["$(location //seobeo/examples:websocket_server_example)"], visibility = ["//visibility:public"], ) + +cc_test( + name = "seobeo_response_test", + srcs = ["seobeo_response_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + +cc_test( + name = "seobeo_sigpipe_test", + srcs = ["seobeo_sigpipe_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + +cc_test( + name = "seobeo_http_timeout_test", + srcs = ["seobeo_http_timeout_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + +cc_test( + name = "seobeo_http_framing_test", + srcs = ["seobeo_http_framing_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + +cc_test( + name = "seobeo_http_content_length_test", + srcs = ["seobeo_http_content_length_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_http_content_length_test.c Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,91 @@ +#include "seobeo/seobeo.h" + +#include <arpa/inet.h> +#include <pthread.h> +#include <stdio.h> +#include <string.h> +#include <time.h> + +typedef struct { + int listener; +} Content_Length_Server; + +static int64_t monotonic_milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static void *serve_lowercase_content_length(void *argument) +{ + Content_Length_Server *server = argument; + int client = accept(server->listener, NULL, NULL); + if (client >= 0) { + char request[1024]; + (void)read(client, request, sizeof(request)); + const char response[] = + "HTTP/1.1 200 OK\r\n" + "content-length: 4\r\n" + "Connection: keep-alive\r\n" + "\r\n" + "done"; + (void)write(client, response, sizeof(response) - 1); + usleep(500000); + close(client); + } + return NULL; +} + +int main(void) +{ + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) + return 1; + + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = 0, + }; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || + listen(listener, 1) != 0) { + close(listener); + return 1; + } + + socklen_t address_length = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) { + close(listener); + return 1; + } + + Content_Length_Server server = {.listener = listener}; + pthread_t thread; + if (pthread_create(&thread, NULL, serve_lowercase_content_length, &server) != 0) { + close(listener); + return 1; + } + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port)); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 1000); + int64_t started = monotonic_milliseconds(); + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + int64_t elapsed = monotonic_milliseconds() - started; + + int failed = !response || + response->body_length != 4 || + memcmp(response->body, "done", 4) != 0 || + elapsed > 300; + if (failed) + fprintf(stderr, "Case-insensitive Content-Length was not honored (%lldms)\n", + (long long)elapsed); + + Seobeo_Client_Response_Destroy(response); + Seobeo_Client_Request_Destroy(request); + pthread_join(thread, NULL); + close(listener); + return failed; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_http_framing_test.c Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,79 @@ +#include "seobeo/seobeo.h" + +#include <arpa/inet.h> +#include <pthread.h> +#include <stdio.h> +#include <string.h> + +typedef struct { + int listener; +} Framing_Server; + +static void *serve_close_delimited_response(void *argument) +{ + Framing_Server *server = argument; + int client = accept(server->listener, NULL, NULL); + if (client >= 0) { + char request[1024]; + (void)read(client, request, sizeof(request)); + const char response[] = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "Connection: close\r\n" + "\r\n" + "buffered-body"; + (void)write(client, response, sizeof(response) - 1); + close(client); + } + return NULL; +} + +int main(void) +{ + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) + return 1; + + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = 0, + }; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || + listen(listener, 1) != 0) { + close(listener); + return 1; + } + + socklen_t address_length = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) { + close(listener); + return 1; + } + + Framing_Server server = {.listener = listener}; + pthread_t thread; + if (pthread_create(&thread, NULL, serve_close_delimited_response, &server) != 0) { + close(listener); + return 1; + } + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port)); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 1000); + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + + int failed = !response || + response->status_code != 200 || + response->body_length != strlen("buffered-body") || + memcmp(response->body, "buffered-body", strlen("buffered-body")) != 0; + if (failed) + fprintf(stderr, "Close-delimited response body was not preserved\n"); + + Seobeo_Client_Response_Destroy(response); + Seobeo_Client_Request_Destroy(request); + pthread_join(thread, NULL); + close(listener); + return failed; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_http_timeout_test.c Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,82 @@ +#include "seobeo/seobeo.h" + +#include <arpa/inet.h> +#include <pthread.h> +#include <stdio.h> +#include <string.h> +#include <time.h> + +typedef struct { + int listener; +} Timeout_Server; + +static int64_t monotonic_milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static void *serve_stalled_response(void *argument) +{ + Timeout_Server *server = argument; + int client = accept(server->listener, NULL, NULL); + if (client >= 0) { + char request[1024]; + (void)read(client, request, sizeof(request)); + usleep(250000); + close(client); + } + return NULL; +} + +int main(void) +{ + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) + return 1; + + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = 0, + }; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || + listen(listener, 1) != 0) { + close(listener); + return 1; + } + + socklen_t address_length = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) { + close(listener); + return 1; + } + + Timeout_Server server = {.listener = listener}; + pthread_t thread; + if (pthread_create(&thread, NULL, serve_stalled_response, &server) != 0) { + close(listener); + return 1; + } + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port)); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 50); + + int64_t started = monotonic_milliseconds(); + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + int64_t elapsed = monotonic_milliseconds() - started; + + int failed = response != NULL || elapsed < 40 || elapsed > 2000; + if (failed) + fprintf(stderr, "Expected a bounded timeout; response=%p elapsed=%lldms\n", + (void *)response, (long long)elapsed); + + Seobeo_Client_Response_Destroy(response); + Seobeo_Client_Request_Destroy(request); + pthread_join(thread, NULL); + close(listener); + return failed; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_response_test.c Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,79 @@ +#include "seobeo/seobeo.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> + +int main(void) +{ + int sockets[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) + return 1; + + Seobeo_Handle handle = {0}; + handle.socket = sockets[0]; + handle.write_buffer_capacity = 4096; + handle.write_buffer = malloc(handle.write_buffer_capacity); + + Dowa_Arena *arena = Dowa_Arena_Create(4096); + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", "200", arena); + Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(response, "body", "hello", arena); + Dowa_HashMap_Push_Arena(response, "X-Test", "present", arena); + Dowa_HashMap_Push_Arena(response, "X-Unsafe", "bad\r\nInjected: yes", arena); + + Seobeo_Router_Send_Response(&handle, response, arena); + + char received[4096] = {0}; + ssize_t length = read(sockets[1], received, sizeof(received) - 1); + int failed = 0; + if (length <= 0) + failed = 1; + if (!strstr(received, "Content-Length: 5\r\n")) + failed = 1; + if (!strstr(received, "X-Test: present\r\n\r\nhello")) + failed = 1; + if (strstr(received, "Injected: yes")) + failed = 1; + + if (failed) + fprintf(stderr, "Unexpected response:\n%s\n", received); + + close(sockets[0]); + close(sockets[1]); + free(handle.write_buffer); + Dowa_Arena_Free(arena); + + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) + return 1; + + memset(&handle, 0, sizeof(handle)); + handle.socket = sockets[0]; + handle.write_buffer_capacity = 4096; + handle.write_buffer = malloc(handle.write_buffer_capacity); + arena = Dowa_Arena_Create(4096); + response = NULL; + Dowa_HashMap_Push_Arena(response, "status", "204", arena); + Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(response, "body", "", arena); + Seobeo_Router_Send_Response(&handle, response, arena); + + memset(received, 0, sizeof(received)); + length = read(sockets[1], received, sizeof(received) - 1); + if (length <= 0 || + !strstr(received, "HTTP/1.1 204 No Content\r\n") || + !strstr(received, "Content-Length: 0\r\n") || + !strstr(received, "\r\n\r\n")) + failed = 1; + + if (failed) + fprintf(stderr, "Unexpected no-content response:\n%s\n", received); + + close(sockets[0]); + close(sockets[1]); + free(handle.write_buffer); + Dowa_Arena_Free(arena); + return failed; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_sigpipe_test.c Sun Aug 02 16:44:06 2026 -0700 @@ -0,0 +1,36 @@ +#include "seobeo/seobeo.h" + +#include <signal.h> +#include <stdlib.h> +#include <sys/socket.h> + +int main(void) +{ + Seobeo_Handle *server = + Seobeo_Stream_Handle_Server_Create("127.0.0.1", "0"); + if (!server) + return 1; + + raise(SIGPIPE); + Seobeo_Handle_Destroy(server); + + int sockets[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) + return 1; + + Seobeo_Handle handle = {0}; + handle.socket = sockets[0]; + handle.write_buffer_capacity = 64; + handle.write_buffer = malloc(handle.write_buffer_capacity); + if (!handle.write_buffer) + return 1; + + close(sockets[1]); + if (Seobeo_Handle_Queue(&handle, (const uint8 *)"closed", 6) != 0) + return 1; + + int result = Seobeo_Handle_Flush(&handle); + close(sockets[0]); + free(handle.write_buffer); + return result < 0 ? 0 : 1; +}