From d1619d94ac9444abe8f65f1b07da8e883d364401 Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Sun, 15 Mar 2026 06:18:49 +0530 Subject: [PATCH 01/12] feat: add monitor-sampler@1.0 device for probabilistic sampling Lightweight device that gates HTTP monitor invocations via 1-in-N probabilistic sampling. Reads `sample-rate` from the request message and rolls `rand:uniform(Rate) =:= 1`. If absent, all requests pass. --- src/dev_monitor_sampler.erl | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/dev_monitor_sampler.erl diff --git a/src/dev_monitor_sampler.erl b/src/dev_monitor_sampler.erl new file mode 100644 index 000000000..078e0f195 --- /dev/null +++ b/src/dev_monitor_sampler.erl @@ -0,0 +1,22 @@ +-module(dev_monitor_sampler). +-export([info/1, should_sample/3]). +-include("include/hb.hrl"). + + +%%% A lightweight device that gates HTTP monitor invocations via probabilistic +%%% sampling. When `sample-rate` is set to N in the monitor config, only +%%% ~1-in-N requests will be forwarded. If absent, every request is forwarded. + +info(_Base) -> + #{default => fun should_sample/3}. + +should_sample(_Base, Req, Opts) -> + ?event({req, Req}), + case hb_maps:get(<<"sample-rate">>, Req, not_found, Opts) of + not_found -> + {ok, true}; + Rate when is_integer(Rate), Rate > 0 -> + {ok, rand:uniform(Rate) =:= 1}; + _Other -> + {ok, true} + end. From 851eddb85f10577ab203f4dfbf471e84bda7069d Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Sun, 15 Mar 2026 06:19:09 +0530 Subject: [PATCH 02/12] feat: register monitor-sampler@1.0 in preloaded devices --- src/hb_opts.erl | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hb_opts.erl b/src/hb_opts.erl index 4d169cf88..8be24807a 100644 --- a/src/hb_opts.erl +++ b/src/hb_opts.erl @@ -205,6 +205,7 @@ default_message() -> #{<<"name">> => <<"message@1.0">>, <<"module">> => dev_message}, #{<<"name">> => <<"meta@1.0">>, <<"module">> => dev_meta}, #{<<"name">> => <<"monitor@1.0">>, <<"module">> => dev_monitor}, + #{<<"name">> => <<"monitor-sampler@1.0">>, <<"module">> => dev_monitor_sampler}, #{<<"name">> => <<"multipass@1.0">>, <<"module">> => dev_multipass}, #{<<"name">> => <<"name@1.0">>, <<"module">> => dev_name}, #{<<"name">> => <<"node-process@1.0">>, <<"module">> => dev_node_process}, From 7cfbc11725e245ed542e8d8fde0e7a0a45b72f77 Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Wed, 18 Mar 2026 19:32:46 +0530 Subject: [PATCH 03/12] feat: add generic chance@1.0 gate device, replace monitor-sampler Add dev_chance.erl as a composable 1-in-N probabilistic gate using the 4-arity default handler pattern. --- src/dev_chance.erl | 23 +++++++++++++++++++++++ src/dev_monitor_sampler.erl | 22 ---------------------- src/hb_opts.erl | 2 +- 3 files changed, 24 insertions(+), 23 deletions(-) create mode 100644 src/dev_chance.erl delete mode 100644 src/dev_monitor_sampler.erl diff --git a/src/dev_chance.erl b/src/dev_chance.erl new file mode 100644 index 000000000..87d7a0751 --- /dev/null +++ b/src/dev_chance.erl @@ -0,0 +1,23 @@ +%%% @doc A generic probabilistic gate device. When accessed as +%%% `~chance@1.0/N', the key `N' is parsed as an integer rate. +%%% A 1-in-N random check is performed: +%%% +%%% {ok, Req} -- check passed, hook chain continues +%%% {error, _} -- check failed, hook chain halts +%%% +%%% This device uses the 4-arity default handler pattern so that the +%%% rate is supplied via the URL path rather than a config key. +-module(dev_chance). +-export([info/1]). +-include("include/hb.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +info(_) -> #{ default => fun default/4 }. + +default(Key, _Base, Req, _Opts) -> + Rate = binary_to_integer(Key), + ?event({request, {rate, Rate}}), + case Rate > 0 andalso rand:uniform(Rate) =:= 1 of + true -> {ok, Req}; + false -> {error, <<"Filtered by chance gate.">>} + end. diff --git a/src/dev_monitor_sampler.erl b/src/dev_monitor_sampler.erl deleted file mode 100644 index 078e0f195..000000000 --- a/src/dev_monitor_sampler.erl +++ /dev/null @@ -1,22 +0,0 @@ --module(dev_monitor_sampler). --export([info/1, should_sample/3]). --include("include/hb.hrl"). - - -%%% A lightweight device that gates HTTP monitor invocations via probabilistic -%%% sampling. When `sample-rate` is set to N in the monitor config, only -%%% ~1-in-N requests will be forwarded. If absent, every request is forwarded. - -info(_Base) -> - #{default => fun should_sample/3}. - -should_sample(_Base, Req, Opts) -> - ?event({req, Req}), - case hb_maps:get(<<"sample-rate">>, Req, not_found, Opts) of - not_found -> - {ok, true}; - Rate when is_integer(Rate), Rate > 0 -> - {ok, rand:uniform(Rate) =:= 1}; - _Other -> - {ok, true} - end. diff --git a/src/hb_opts.erl b/src/hb_opts.erl index 8be24807a..3fdcbacc2 100644 --- a/src/hb_opts.erl +++ b/src/hb_opts.erl @@ -205,7 +205,7 @@ default_message() -> #{<<"name">> => <<"message@1.0">>, <<"module">> => dev_message}, #{<<"name">> => <<"meta@1.0">>, <<"module">> => dev_meta}, #{<<"name">> => <<"monitor@1.0">>, <<"module">> => dev_monitor}, - #{<<"name">> => <<"monitor-sampler@1.0">>, <<"module">> => dev_monitor_sampler}, + #{<<"name">> => <<"chance@1.0">>, <<"module">> => dev_chance}, #{<<"name">> => <<"multipass@1.0">>, <<"module">> => dev_multipass}, #{<<"name">> => <<"name@1.0">>, <<"module">> => dev_name}, #{<<"name">> => <<"node-process@1.0">>, <<"module">> => dev_node_process}, From 8f2cdac7580f54b3a0d1d185033d9b5a6b1a8050 Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Fri, 10 Apr 2026 14:49:35 -0400 Subject: [PATCH 04/12] fix: validate chance@1.0 rate before parsing binary_to_integer/1 throws badarg on a non-integer path. Wrap in try/catch and reject non-positive rates explicitly so callers get a proper error tuple instead of a crash. --- src/dev_chance.erl | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/dev_chance.erl b/src/dev_chance.erl index 87d7a0751..6d99ad969 100644 --- a/src/dev_chance.erl +++ b/src/dev_chance.erl @@ -15,9 +15,16 @@ info(_) -> #{ default => fun default/4 }. default(Key, _Base, Req, _Opts) -> - Rate = binary_to_integer(Key), - ?event({request, {rate, Rate}}), - case Rate > 0 andalso rand:uniform(Rate) =:= 1 of - true -> {ok, Req}; - false -> {error, <<"Filtered by chance gate.">>} + try binary_to_integer(Key) of + Rate when Rate > 0 -> + ?event({request, {rate, Rate}}), + case rand:uniform(Rate) =:= 1 of + true -> {ok, Req}; + false -> {error, <<"Filtered by chance gate.">>} + end; + _ -> + {error, <<"chance@1.0 rate must be a positive integer.">>} + catch + error:badarg -> + {error, <<"chance@1.0 rate must be a valid integer.">>} end. From f70c3b85f0ae49a28ee0b6b5a737661bb59ac0ed Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Tue, 14 Apr 2026 20:44:50 -0400 Subject: [PATCH 05/12] fix: `relay-path` being dropped when matched at root `path` --- src/dev_relay.erl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dev_relay.erl b/src/dev_relay.erl index c5782ff1f..9760fb63c 100644 --- a/src/dev_relay.erl +++ b/src/dev_relay.erl @@ -36,10 +36,10 @@ call(M1, RawM2, Opts) -> RelayPath = hb_ao:get_first( [ - {M1, <<"path">>}, - {{as, <<"message@1.0">>, BaseTarget}, <<"path">>}, {RawM2, <<"relay-path">>}, - {M1, <<"relay-path">>} + {M1, <<"relay-path">>}, + {M1, <<"path">>}, + {{as, <<"message@1.0">>, BaseTarget}, <<"path">>} ], Opts ), From c9abedcb724315b6ebe3845e4164dc9a0461f310 Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Tue, 14 Apr 2026 20:49:40 -0400 Subject: [PATCH 06/12] fix: `http-reference` being undefined Merging of Opts happen inside `hb_http:request` which gets called after the the `multi` flow because of which the Node opts are never in Opts defaulting `http-ref` to be undefined. Hence, Node is funnel down to `is_addmissible` to extract opts --- src/hb_http_multi.erl | 23 ++++++++++++----------- src/hb_store_remote_node.erl | 13 ++++++++++--- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/hb_http_multi.erl b/src/hb_http_multi.erl index 5795acfa2..29d680296 100644 --- a/src/hb_http_multi.erl +++ b/src/hb_http_multi.erl @@ -129,7 +129,7 @@ multirequest_opt(Key, Config, Message, Default, Opts) -> %% %% If the response is `ok', we check the status and the response message against %% the configuration. -is_admissible(ok, Res, Admissible, Statuses, Opts) -> +is_admissible(ok, Res, Admissible, Statuses, Node, Opts) -> ?event(debug_multi, {is_admissible, {response, Res}, @@ -139,10 +139,11 @@ is_admissible(ok, Res, Admissible, Statuses, Opts) -> ), AdmissibleStatus = admissible_status(Res, Statuses), ?event(debug_multi, {admissible_status, {result, AdmissibleStatus}}), - AdmissibleResponse = admissible_response(Res, Admissible, Opts), + NodeOpts = hb_maps:get(<<"opts">>, Node, Opts), + AdmissibleResponse = admissible_response(Res, Admissible, NodeOpts, Opts), ?event(debug_multi, {admissible_response, {result, AdmissibleResponse}}), AdmissibleStatus andalso AdmissibleResponse; -is_admissible(_, _, _, _, _) -> false. +is_admissible(_, _, _, _, _, _) -> false. %% @doc Serially request a message, collecting responses until the required %% number of responses have been gathered. Ensure that the statuses are @@ -153,7 +154,7 @@ serial_multirequest(_Nodes, 0, _Method, _Path, _Message, _Admissible, _Statuses, serial_multirequest([], _, _Method, _Path, _Message, _Admissible, _Statuses, _Opts) -> {[], []}; serial_multirequest([Node|Nodes], Remaining, Method, Path, Message, Admissible, Statuses, Opts) -> {ErlStatus, Res} = hb_http:request(Method, Node, Path, Message, Opts), - case is_admissible(ErlStatus, Res, Admissible, Statuses, Opts) of + case is_admissible(ErlStatus, Res, Admissible, Statuses, Node, Opts) of true -> ?event(debug_http, {admissible_status, {response, Res}}), {AdmissibleAcc, AllAcc} = serial_multirequest( @@ -200,12 +201,12 @@ start_workers(Count, Ref, Nodes, Method, Path, Message, Opts) -> fun(Node) -> spawn( fun() -> - Res = + {Status, Res} = try hb_http:request(Method, Node, Path, Message, Opts) catch C:R -> {error, {worker_crash, C, R}} end, receive no_reply -> stopping - after 0 -> Parent ! {Ref, self(), Res} + after 0 -> Parent ! {Ref, self(), {Status, Res, Node}} end end ) @@ -236,8 +237,8 @@ admissible_status(Status, Statuses) when is_list(Statuses) -> %% @doc If an `admissable` message is set for the request, check if the response %% adheres to it. Else, return `true'. -admissible_response(_Response, undefined, _Opts) -> true; -admissible_response(Response, IsAdmissible, Opts) -> +admissible_response(_Response, undefined, _NodeOpts, _Opts) -> true; +admissible_response(Response, IsAdmissible, NOpts, Opts) -> Req = IsAdmissible#{ <<"path">> => @@ -248,7 +249,7 @@ admissible_response(Response, IsAdmissible, Opts) -> Opts ), <<"body">> => Response, - <<"http-reference">> => hb_opts:get(http_reference, undefined, Opts) + <<"http-reference">> => hb_opts:get(http_reference, undefined, NOpts) }, ?event(debug_admissible, {admissible_response, {request, Req}, {opts, Opts}}), try hb_ao:resolve(Req, Opts#{ hashpath => ignore }) of @@ -287,13 +288,13 @@ parallel_responses(AdmissibleRes, AllRes, Procs, _, _, Ref, 0, true, _Admissible {AdmissibleRes, AllRes}; parallel_responses(AdmissibleRes, AllRes, Procs, Queue, {Method, Path, Message}, Ref, Awaiting, StopAfter, Admissible, Statuses, Opts) -> receive - {Ref, Pid, {Status, NewRes}} -> + {Ref, Pid, {Status, NewRes, Node}} -> WorkersWithoutPid = lists:delete(Pid, Procs), {RefilledWorkers, NewQueue} = start_workers(1, Ref, Queue, Method, Path, Message, Opts), NewProcs = RefilledWorkers ++ WorkersWithoutPid, NewAllRes = [{Status, NewRes} | AllRes], - case is_admissible(Status, NewRes, Admissible, Statuses, Opts) of + case is_admissible(Status, NewRes, Admissible, Statuses, Node, Opts) of true -> parallel_responses( [{Status, NewRes} | AdmissibleRes], diff --git a/src/hb_store_remote_node.erl b/src/hb_store_remote_node.erl index 7845c0e54..3c06b4c77 100644 --- a/src/hb_store_remote_node.erl +++ b/src/hb_store_remote_node.erl @@ -104,7 +104,8 @@ read(StoreOpts = #{ <<"nodes">> := Nodes }, Key) -> [ #{ <<"template">> => <<"/~cache@1.0/read">>, - <<"nodes">> => Nodes + <<"nodes">> => Nodes, + <<"parallel">> => true } ] } @@ -281,8 +282,14 @@ multinode_env() -> <<"store-module">> => hb_store_remote_node, <<"max-retries">> => 0, <<"nodes">> => [ - #{ <<"prefix">> => Node1, <<"http-reference">> => <<"node1">> }, - #{ <<"prefix">> => Node2, <<"http-reference">> => <<"node2">> } + #{ + <<"prefix">> => Node1, + <<"opts">> => #{ <<"http-reference">> => <<"node1">> } + }, + #{ + <<"prefix">> => Node2, + <<"opts">> => #{ <<"http-reference">> => <<"node2">> } + } ], <<"parallel">> => 1 }, From a11e66c9a932187fb15d00423c767d5be758d58b Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Wed, 15 Apr 2026 00:30:43 -0400 Subject: [PATCH 07/12] feat: admissible-response hook forwards reference and status to relay - dev_cache:expected_response extracts `http-reference` from Base, builds a forward body with reference + status-class + event tags - Optional commit_hook_response flag signs the forward body before relay - Fix parallel default in multinode_env test helper --- src/dev_cache.erl | 19 ++++++++++++++++++- src/hb_store_remote_node.erl | 9 +++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/dev_cache.erl b/src/dev_cache.erl index 5dde59030..ddc465b9f 100644 --- a/src/dev_cache.erl +++ b/src/dev_cache.erl @@ -18,7 +18,7 @@ expected_response(Base, Req, Opts) -> true ?= check_response_matches_expected(Response, Expected, Opts), dev_hook:on( [<<"~cache@1.0">>, <<"admissible-response">>], - Response, + #{ <<"body">> => admissible_response_body(Base, Opts) }, Opts ), {ok, true} @@ -84,6 +84,23 @@ check_response_matches_expected(Response, Expected, Opts) -> true end. +%% @doc Build the body for the `admissible-response' hook. Optionally signs it +%% when `commit_hook_response' is set, so downstream handlers can verify the +%% node attested to admissibility. +admissible_response_body(Base, Opts) -> + Ref = hb_maps:get(<<"http-reference">>, Base, <<>>, Opts), + Body = #{ + <<"reference">> => Ref, + <<"status-class">> => <<"success">>, + <<"event">> => <<"is_admissible">> + }, + case hb_opts:get(commit_hook_response, false, Opts) of + true -> + hb_message:commit(Body, Opts#{ priv_wallet => hb:wallet() }); + _ -> + Body + end. + %% @doc Read data from the cache. %% Retrieves data corresponding to a key from a local store. %% The key is extracted from the incoming message under <<"target">>. diff --git a/src/hb_store_remote_node.erl b/src/hb_store_remote_node.erl index 3c06b4c77..17483538f 100644 --- a/src/hb_store_remote_node.erl +++ b/src/hb_store_remote_node.erl @@ -95,10 +95,15 @@ read(StoreOpts = #{ <<"nodes">> := Nodes }, Key) -> error -> #{} end, ?event({remote_read, {request, HTTPReq}, {hooks, MaybeHooks}}), + MaybeCommitHookRes = + case maps:find(<<"commit-hook-response">>, StoreOpts) of + {ok, true} -> MaybeHooks#{ commit_hook_response => true }; + _ -> MaybeHooks + end, HTTPRes = hb_http:request( HTTPReq, - MaybeHooks#{ + MaybeCommitHookRes#{ cache_control => [<<"no-cache">>, <<"no-store">>], routes => [ @@ -291,7 +296,7 @@ multinode_env() -> <<"opts">> => #{ <<"http-reference">> => <<"node2">> } } ], - <<"parallel">> => 1 + <<"parallel">> => true }, #{ ids_single => [ID1, ID2], From 5ce84c119cbfa5758cddfb796952ecf78f878606 Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Thu, 23 Apr 2026 20:50:12 -0400 Subject: [PATCH 08/12] fix: lowercase response headers before codec conversion RFC 7230 - Title-Case headers introduced over the wire because of HTTP\1 - Normalise at the transport boundary so every codec sees stable keys regardless of peer or proxy. Does not touch signed message content. --- src/hb_http.erl | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/hb_http.erl b/src/hb_http.erl index fefaf4f26..3f6ec6208 100644 --- a/src/hb_http.erl +++ b/src/hb_http.erl @@ -283,9 +283,10 @@ http_response_to_httpsig(Status, HeaderMap, Body, Opts) -> 0 -> #{}; _ -> #{ <<"body">> => Body } end, - ConvertFrom = + NormalizedHeaders = lowercase_header_keys(HeaderMap), + ConvertFrom = hb_maps:merge( - HeaderMap#{ <<"status">> => BinStatus }, + NormalizedHeaders#{ <<"status">> => BinStatus }, BodyMap, Opts ), @@ -296,6 +297,19 @@ http_response_to_httpsig(Status, HeaderMap, Body, Opts) -> Opts ))#{ <<"status">> => hb_util:int(Status) }. +%% @doc Lowercase all binary header keys. Applied at every inbound transport +%% boundary so codecs see stable keys regardless of peer or proxy casing. +%% HTTP/1.1 (RFC 7230) field names are case-insensitive; HTTP/2 (RFC 7540) +%% mandates lowercase. Values are left untouched. +lowercase_header_keys(Headers) when is_map(Headers) -> + maps:fold( + fun(K, V, Acc) when is_binary(K) -> Acc#{string:lowercase(K) => V}; + (K, V, Acc) -> Acc#{K => V} + end, + #{}, + Headers + ). + %% @doc Given a message, return the information needed to make the request. message_to_request(M, Opts) -> % Get the route for the message @@ -854,7 +868,7 @@ req_to_tabm_singleton(Req, Body, Opts) -> "?", (cowboy_req:qs(Req))/binary >>, - Headers = cowboy_req:headers(Req), + Headers = lowercase_header_keys(cowboy_req:headers(Req)), {ok, _Path, QueryKeys} = hb_singleton:from_path(FullPath), PrimitiveMsg = maps:merge(Headers, QueryKeys), Codec = From 3173dec398d25cf6cf1af3211828ee20dcc7f100 Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Thu, 23 Apr 2026 23:30:53 -0400 Subject: [PATCH 09/12] feat: redirect manifest loads to canonical b32 subdomain SPAs served from manifests use absolute asset paths that 404 when the manifest is hit on the root domain. Redirect plain path requests for a manifest TX to its `.` subdomain so the browser resolves assets correctly. --- src/dev_b32_name.erl | 2 + src/dev_manifest.erl | 89 +++++++++++++++++++++++++++++--------------- src/dev_name.erl | 2 +- src/hb_http.erl | 3 +- 4 files changed, 63 insertions(+), 33 deletions(-) diff --git a/src/dev_b32_name.erl b/src/dev_b32_name.erl index e35c2200d..ab6ca1842 100644 --- a/src/dev_b32_name.erl +++ b/src/dev_b32_name.erl @@ -2,6 +2,8 @@ %%% subdomains on a HyperBEAM node. -module(dev_b32_name). -export([info/1]). +%%% Public helpers. +-export([ encode/1]). -include("include/hb.hrl"). -include_lib("eunit/include/eunit.hrl"). diff --git a/src/dev_manifest.erl b/src/dev_manifest.erl index 395cc2492..62c13ed96 100644 --- a/src/dev_manifest.erl +++ b/src/dev_manifest.erl @@ -87,50 +87,77 @@ route(Key, M1, M2, Opts) -> %% @doc Implement the `on/request' hook for the `manifest@1.0' device, finding %% requests for legacy (non-device-tagged) manifests and casting them to -%% `manifest@1.0' before execution. Allowing `/ID/path` style access for old data. +%% `manifest@1.0' before execution. When a manifest is hit on the root domain +%% it redirects to its canonical b32 subdomain so browsers resolve absolute +%% asset paths correctly. request(Base, Req, Opts) -> ?event({on_req_manifest_detector, {base, Base}, {req, Req}}), maybe {ok, [PrimaryMsg|Rest]} ?= hb_maps:find(<<"body">>, Req, Opts), {ok, Loaded} ?= load(PrimaryMsg, Opts), ?event(debug_manifest, {loaded, Loaded}), - % Must handle three cases: - % 1. The maybe_cast is not a manifest, so we return the *loaded* request, - % such that the work to load it is not wasted. - % 2. The maybe_cast is a manifest, and there are no other elements of - % the path, so we add the `index' path and return. - % 3. The maybe_cast is a manifest, and there are other elements of - % the path, so we return the original request sequence with the first - % message replaced with the casted manifest. - case {Rest, maybe_cast_manifest(Loaded, Opts)} of - {_, ignored} -> - ?event( - debug_manifest, - {non_manifest_returning_loaded, {loaded, Loaded}, {rest, Rest}}), + case maybe_cast_manifest(Loaded, Opts) of + ignored -> {ok, Req#{ <<"body">> => [Loaded|Rest] }}; - {[], {ok, Casted}} -> - ?event(debug_manifest, {manifest_returning_index, {req, Req}}), - {ok, Req#{ <<"body">> => [Casted, #{<<"path">> => <<"index">>}] }}; - {_, {ok, Casted}} -> - ?event(debug_manifest, {manifest_returning_subpath, {req, Req}}), - {ok, Req#{ <<"body">> => [Casted|Rest] }} + {ok, Casted} -> + serve_or_redirect(PrimaryMsg, Casted, Rest, Req, Opts) end else {error, not_found} -> - ?event(debug_manifest, {not_found_on_load, {req, Req}}), - { - error, - #{ - <<"status">> => 404, - <<"body">> => <<"Not Found">> - } - }; - Error -> - ?event(debug_manifest, {request_ignored, {unexpected, Error}}), - % On other errors, we return the original request. + {error, #{<<"status">> => 404, <<"body">> => <<"Not Found">>}}; + _ -> {ok, Req} end. +serve_or_redirect(TxId, Casted, Rest, Req, Opts) -> + case needs_b32_redirect(TxId, Rest, Req, Opts) of + {redirect, Url} -> + redirect_response(Url); + no_redirect when Rest =:= [] -> + {ok, Req#{<<"body">> => [Casted, #{<<"path">> => <<"index">>}]}}; + no_redirect -> + {ok, Req#{<<"body">> => [Casted|Rest]}} + end. + +%% @doc A redirect is needed when the URL is `//...' on the root domain +%% and no device invocations in the tail, host present, and not already on the +%% canonical `.' subdomain. +needs_b32_redirect(TxId, Rest, Req, Opts) when ?IS_ID(TxId) -> + ReqInner = hb_maps:get(<<"request">>, Req, #{}, Opts), + Host = hb_maps:get(<<"host">>, ReqInner, <<>>, Opts), + B32 = dev_b32_name:encode(TxId), + Already = dev_name:name_from_host(Host, hb_opts:get(node_host, no_host, Opts)), + PlainTail = lists:all(fun(X) -> plain_path_segment(X, Opts) end, Rest), + case {Host =/= <<>>, PlainTail, Already} of + {true, true, NotCanonical} when NotCanonical =/= {ok, B32} -> + {redirect, b32_url(B32, ReqInner, Opts)}; + _ -> no_redirect + end; +needs_b32_redirect(_, _, _, _) -> no_redirect. + +%% @doc A plain path segment is a bare ID binary or a map without a device key. +plain_path_segment(X, _Opts) when is_binary(X) -> true; +plain_path_segment(M, Opts) when is_map(M) -> + not hb_maps:is_key(<<"device">>, M, Opts); +plain_path_segment(_,_) -> false. + +b32_url(B32, ReqInner, Opts) -> + Host = hb_maps:get(<<"host">>, ReqInner, <<>>, Opts), + Path = hb_maps:get(<<"path">>, ReqInner, <<"/">>, Opts), + HostPort = case hb_maps:get(<<"port">>, ReqInner, undefined, Opts) of + P when P == undefined; P == 80; P == 443 -> Host; + P -> <> + end, + <<"//", B32/binary, ".", HostPort/binary, Path/binary>>. + +redirect_response(Url) -> + ?event(debug_manifest, {b32_redirect, {url, Url}}), + {error, #{ + <<"status">> => 302, + <<"location">> => Url, + <<"body">> => <<"Redirecting to canonical manifest subdomain: ", Url/binary>> + }}. + %% @doc Cast a message to `manifest@1.0` if it has the correct content-type but %% no other device is specified. load(Msg, _Opts) when is_map(Msg) -> {ok, Msg}; diff --git a/src/dev_name.erl b/src/dev_name.erl index 1331e3f2d..8d6b87955 100644 --- a/src/dev_name.erl +++ b/src/dev_name.erl @@ -6,7 +6,7 @@ -module(dev_name). -export([info/1, request/3]). %%% Public helpers. --export([test_arns_opts/0]). +-export([test_arns_opts/0, name_from_host/2]). -include("include/hb.hrl"). -include_lib("eunit/include/eunit.hrl"). diff --git a/src/hb_http.erl b/src/hb_http.erl index 3f6ec6208..4157fab99 100644 --- a/src/hb_http.erl +++ b/src/hb_http.erl @@ -1110,7 +1110,8 @@ normalize_unsigned(PrimMsg, Req = #{ headers := RawHeaders }, Msg, Opts) -> Device -> WithPrivIP#{<<"device">> => Device} end, Host = cowboy_req:host(Req), - WithDevice#{<<"host">> => Host}. + Port = cowboy_req:port(Req), + WithDevice#{<<"host">> => Host, <<"port">> => Port}. %% @doc Determine the caller, honoring the `x-real-ip' header if present. real_ip(Req = #{ headers := RawHeaders }, Opts) -> From c5277b1c56720b73872315eeec93948494385d39 Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Fri, 24 Apr 2026 01:40:20 -0400 Subject: [PATCH 10/12] fix: handle `:` in original-tags siginfo names Decoder required exactly 3 colon-separated parts, crashing on real tags like `topic:`. Parse as first-colon/last-colon bounded instead. --- src/dev_codec_httpsig_siginfo.erl | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/dev_codec_httpsig_siginfo.erl b/src/dev_codec_httpsig_siginfo.erl index 493f9421e..5f857e698 100644 --- a/src/dev_codec_httpsig_siginfo.erl +++ b/src/dev_codec_httpsig_siginfo.erl @@ -284,9 +284,15 @@ decoding_nested_map_binary(Bin) -> lists:foldl( fun (X, Acc) -> case binary:split(X, <<":">>, [global]) of - [ID, Key, Value] -> + [ID | Rest] when length(Rest) >= 2 -> + {KeyParts, [Value]} = + lists:split(length(Rest) - 1, Rest), + Key = + iolist_to_binary( + lists:join(<<":">>, KeyParts) + ), Acc#{ - ID => #{ + ID => #{ <<"name">> => Key, <<"value">> => hb_util:decode(Value) } From acf1e0ca3338471c2662353e7a0c984fd373dffa Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Mon, 4 May 2026 14:30:32 -0400 Subject: [PATCH 11/12] fix: run the hooks async while serving the requests --- src/dev_cache.erl | 89 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/src/dev_cache.erl b/src/dev_cache.erl index ddc465b9f..43b910312 100644 --- a/src/dev_cache.erl +++ b/src/dev_cache.erl @@ -16,17 +16,39 @@ expected_response(Base, Req, Opts) -> {ok, Response} ?= hb_maps:find(<<"body">>, Req, Opts), {ok, Expected} ?= hb_maps:find(<<"expected">>, Base, Opts), true ?= check_response_matches_expected(Response, Expected, Opts), - dev_hook:on( - [<<"~cache@1.0">>, <<"admissible-response">>], - #{ <<"body">> => admissible_response_body(Base, Opts) }, - Opts - ), + fire_admissible_response_hook(Base, Opts), {ok, true} else Reason -> ?event(debug_admissible, {expected_response_error, Reason}), {ok, false} end. +%% @doc Fire the `admissible-response' hook. Spawns by default so the cache +%% read returns without waiting for downstream handlers. +%% Set `admissible_response_hook_async => false' in `Opts' to run synchronously +fire_admissible_response_hook(Base, Opts) -> + Run = + fun() -> + dev_hook:on( + [<<"~cache@1.0">>, <<"admissible-response">>], + #{ <<"body">> => admissible_response_body(Base, Opts) }, + Opts + ) + end, + case hb_opts:get(admissible_response_hook_async, true, Opts) of + false -> Run(); + _ -> + spawn(fun() -> + try Run() + catch C:R:S -> + ?event(error, + {admissible_response_hook_async_error, + {class, C}, {reason, R}, + {stacktrace, {trace, S}}}) + end + end) + end. + %% @doc Verify that a response from a remote cache matches the expected ID. %% There are three cases: %% 1. The response is a raw binary (e.g., a direct content-addressed read @@ -441,4 +463,59 @@ cache_write_binary_test() -> ?event(dev_cache, {cache_api_test, {data_read, ReadData}}), ?assertEqual(TestData, ReadData), ?event(dev_cache, {cache_api_test}), - ok. \ No newline at end of file + ok. + +%% @doc Compare wall-clock time of `fire_admissible_response_hook/2' with the +%% async flag on vs off. The hook handler sleeps `HookDelayMs' before signalling +%% so sync mode must take >= HookDelayMs and async mode must return immediately. +fire_admissible_response_hook_async_speed_test() -> + HookDelayMs = 500, + Self = self(), + Handler = #{ + <<"path">> => <<"hook">>, + <<"device">> => #{ + hook => + fun(_, Req, _) -> + timer:sleep(HookDelayMs), + Self ! hook_ran, + {ok, Req} + end + } + }, + BaseOpts = #{ + on => #{ + <<"~cache@1.0">> => + #{ <<"admissible-response">> => Handler } + } + }, + Base = #{}, + %% --- Sync run: fire_admissible_response_hook blocks for the full sleep. + {SyncMicros, _} = + timer:tc(fun() -> + fire_admissible_response_hook(Base, + BaseOpts#{ admissible_response_hook_async => false }) + end), + SyncMs = SyncMicros div 1000, + receive hook_ran -> ok + after HookDelayMs * 4 -> error(sync_hook_never_ran) + end, + %% --- Async run: returns immediately; hook runs in spawned process. + {AsyncMicros, _} = + timer:tc(fun() -> + fire_admissible_response_hook(Base, + BaseOpts#{ admissible_response_hook_async => true }) + end), + AsyncMs = AsyncMicros div 1000, + receive hook_ran -> ok + after HookDelayMs * 4 -> error(async_hook_never_ran) + end, + Speedup = case AsyncMs of 0 -> infinity; _ -> SyncMs div AsyncMs end, + io:format( + user, + "~nadmissible_response_hook_speed: " + "hook_delay=~pms sync=~pms async=~pms speedup=~px~n", + [HookDelayMs, SyncMs, AsyncMs, Speedup] + ), + ?assert(SyncMs >= HookDelayMs), + ?assert(AsyncMs < HookDelayMs div 2), + ?assert(SyncMs > AsyncMs * 4). \ No newline at end of file From 91d86c3abd0e2ff05ac9dc4ee2eb76517d3ea764 Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Mon, 4 May 2026 15:12:45 -0400 Subject: [PATCH 12/12] impr: Log host information from user request --- src/dev_b32_name.erl | 2 +- src/dev_name.erl | 2 +- src/hb_http.erl | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/dev_b32_name.erl b/src/dev_b32_name.erl index ab6ca1842..af05b393d 100644 --- a/src/dev_b32_name.erl +++ b/src/dev_b32_name.erl @@ -1,7 +1,7 @@ %%% @doc Allows Arweave message IDs to be used via their base32 encoding as %%% subdomains on a HyperBEAM node. -module(dev_b32_name). --export([info/1]). +-export([info/1, decode/1]). %%% Public helpers. -export([ encode/1]). -include("include/hb.hrl"). diff --git a/src/dev_name.erl b/src/dev_name.erl index 8d6b87955..04655bc32 100644 --- a/src/dev_name.erl +++ b/src/dev_name.erl @@ -4,7 +4,7 @@ %%% match the key against each resolver in turn, and return the value of the %%% first resolver that matches. -module(dev_name). --export([info/1, request/3]). +-export([info/1, request/3, name_from_host/2]). %%% Public helpers. -export([test_arns_opts/0, name_from_host/2]). -include("include/hb.hrl"). diff --git a/src/hb_http.erl b/src/hb_http.erl index 4157fab99..61ac40dfc 100644 --- a/src/hb_http.erl +++ b/src/hb_http.erl @@ -543,6 +543,7 @@ reply(InitReq, TABMReq, RawStatus, RawMessage, Opts) -> ?event(http_server_short, {sent, {status, Status}, + {host, get_host(TABMReq, Opts)}, {duration, EndTime - hb_maps:get(start_time, Req, undefined, Opts)}, {body_size, byte_size(EncodedBody)}, {method, cowboy_req:method(Req)}, @@ -1127,6 +1128,18 @@ real_ip(Req = #{ headers := RawHeaders }, Opts) -> IP -> IP end. +get_host(TABMReq, Opts) -> + Host = hb_maps:get(<<"host">>, TABMReq, <<"no_host">>, Opts), + MsgNode = hb_opts:get(node_host, hb_opts:get(host, no_host, Opts), Opts), + case dev_name:name_from_host(Host, MsgNode) of + {ok, Name} -> + case dev_b32_name:decode(Name) of + error -> Name; + TXID -> {decoded, {explicit, TXID}} + end; + {skip, _} -> no_host + end. + %%% Metrics init_prometheus() ->