diff --git a/next/kernel/discovery_type_fetch.erl b/next/kernel/discovery_type_fetch.erl new file mode 100644 index 00000000..7f156a90 --- /dev/null +++ b/next/kernel/discovery_type_fetch.erl @@ -0,0 +1,118 @@ +-module(discovery_type_fetch). +-export([make_fetch_fn/0, make_fetch_fn/1, + fetch/2, + type_doc_url/2, + resolve_type_url/2, + accept_header/0]). + +%% Live type-doc fetch for peer_types — host-type federation Step 3, +%% the sibling of discovery_fetch.erl. peer_types:lookup_or_fetch/3 +%% calls a Cfg-supplied type_fetch_fn :: fun ((TypeCid, Cfg) -> {ok, +%% Bytes} | {error, _}) on a cache miss; this module produces that +%% closure for live federation. It GETs /types/ with an +%% Accept header that asks for the type-doc format (http_server.erl +%% Step 3) and returns the RAW response bytes — peer_types decodes +%% them via term_codec into the TypeRecord. (This is the one shape +%% difference from discovery_fetch, whose closure returns an already- +%% decoded actor-state: there the cache stores the decoded AS, here +%% peer_types owns the decode so the type-doc wire format lives in one +%% place — the /types/ route encodes, peer_types decodes.) +%% +%% Cfg shape (parallels discovery_fetch's peer URL resolution): +%% {type_url, [{TypeCid, BaseUrl}, ...]} +%% {type_url_fn, fun ((TypeCid) -> {ok, BaseUrl} | not_found)} +%% +%% BaseUrl shape: <<"http://host:port">> (no trailing slash; this +%% module appends the path). TypeCid is the type's CID bytes. +%% +%% Outcomes: +%% 2xx -> {ok, Bytes} +%% non-2xx -> {error, {status, N}} +%% resolver miss -> {error, no_type_url} +%% transport -> {error, Reason} + +%% ── Accept header ──────────────────────────────────────────── +%% "application/vnd.fed-sx.type-doc" — same MIME http_server's +%% content_type_for(type_doc) emits, so the Accept negotiation routes +%% the served bytes to the term_codec-encoded TypeRecord arm. +accept_header() -> + <<97,112,112,108,105,99,97,116,105,111,110,47, + 118,110,100,46,102,101,100,45,115,120,46, + 116,121,112,101,45,100,111,99>>. + +%% ── public API ─────────────────────────────────────────────── + +%% make_fetch_fn/0 — the fun/2 peer_types:lookup_or_fetch calls. It +%% reads the type-URL resolver out of the Cfg passed at call time, so +%% the same Cfg threads through peer_types and this closure. +make_fetch_fn() -> + fun (TypeCid, Cfg) -> + case resolve_type_url(TypeCid, Cfg) of + {error, R} -> {error, R}; + {ok, BaseUrl} -> fetch(type_doc_url(BaseUrl, TypeCid), Cfg) + end + end. + +%% make_fetch_fn/1 — variant that closes over a static Cfg for the +%% resolver while still honouring the call-time Cfg for transport. +%% Lets a caller bake the type_url map once and reuse the closure. +make_fetch_fn(StaticCfg) -> + fun (TypeCid, Cfg) -> + case resolve_type_url(TypeCid, StaticCfg) of + {error, R} -> {error, R}; + {ok, BaseUrl} -> fetch(type_doc_url(BaseUrl, TypeCid), Cfg) + end + end. + +fetch(Url, _Cfg) -> + AcceptKey = <<97,99,99,101,112,116>>, % "accept" + Headers = [{AcceptKey, accept_header()}], + try httpc:request(Url, get, Headers, <<>>) of + {ok, Status, _H, Body} when Status >= 200, Status < 300 -> + {ok, Body}; + {ok, Status, _H, _B} -> + {error, {status, Status}}; + Other -> + {error, {bad_response, Other}} + catch + error:Reason -> {error, Reason} + end. + +%% type_doc_url/2 — /types/. TypeCid is the cid bytes, +%% appended verbatim as the path segment (matches the "/types/" prefix +%% http_server.erl registers). +type_doc_url(BaseUrl, TypeCid) when is_binary(TypeCid) -> + %% "/types/" — 7 bytes + Prefix = <<47,116,121,112,101,115,47>>, + <>. + +%% resolve_type_url/2 — map a TypeCid to its serving node's base URL. +%% type_url_fn (a 1-arity closure) takes precedence over the static +%% type_url proplist; absent both -> {error, no_type_url}. +resolve_type_url(TypeCid, Cfg) -> + case field(type_url_fn, Cfg) of + Fn when is_function(Fn, 1) -> + case Fn(TypeCid) of + {ok, BaseUrl} -> {ok, BaseUrl}; + _ -> {error, no_type_url} + end; + _ -> + case field(type_url, Cfg) of + nil -> {error, no_type_url}; + Map -> + case find_keyed(TypeCid, Map) of + {ok, BaseUrl} -> {ok, BaseUrl}; + _ -> {error, no_type_url} + end + end + end. + +%% ── helpers ────────────────────────────────────────────────── + +field(K, [{K, V} | _]) -> V; +field(K, [_ | Rest]) -> field(K, Rest); +field(_, []) -> nil. + +find_keyed(_, []) -> {error, not_found}; +find_keyed(K, [{K, V} | _]) -> {ok, V}; +find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest). diff --git a/next/kernel/http_server.erl b/next/kernel/http_server.erl index 1ac55d73..6e1b3b35 100644 --- a/next/kernel/http_server.erl +++ b/next/kernel/http_server.erl @@ -4,6 +4,7 @@ welcome_body/0, capabilities_body/0, capabilities_path/0, match_prefix/2, actors_prefix/0, actor_doc_response/1, + types_prefix/0, type_doc_response_for/2, artifacts_prefix/0, artifact_response/1, projections_list_path/0, projections_prefix/0, projections_list_response/0, projection_response/1, @@ -156,7 +157,12 @@ dispatch(<<71, 69, 84>>, Path, F, Cfg) -> {ok, Name} when byte_size(Name) > 0 -> projection_response_for(Name, F); _ -> - not_found_response() + case match_prefix(types_prefix(), Path) of + {ok, Cid} when byte_size(Cid) > 0 -> + type_doc_response_for(Cid, Cfg); + _ -> + not_found_response() + end end end end; @@ -289,6 +295,10 @@ artifact_response(Cid) -> Body = <
>,
     ok_response(Body).
 
+%% "/types/" — 7 bytes: 47 116 121 112 101 115 47 (host-type fed Step 3)
+types_prefix() ->
+    <<47,116,121,112,101,115,47>>.
+
 %% "/projections" — 12 bytes (no trailing slash; the list endpoint)
 projections_list_path() ->
     <<47,112,114,111,106,101,99,116,105,111,110,115>>.
@@ -488,9 +498,20 @@ actor_doc_prefix() ->
       118,110,100,46,102,101,100,45,115,120,46,
       97,99,116,111,114,45,100,111,99>>.
 
+%% "application/vnd.fed-sx.type-doc" — 31 bytes (host-type fed Step 3).
+%% Distinct from actor-doc: the body is a term_codec-encoded
+%% TypeRecord (peer_types cache entry), not a peer-actor-state.
+type_doc_prefix() ->
+    <<97,112,112,108,105,99,97,116,105,111,110,47,
+      118,110,100,46,102,101,100,45,115,120,46,
+      116,121,112,101,45,100,111,99>>.
+
 accept_format(nil) -> text;
 accept_format(<<>>) -> text;
 accept_format(V) when is_binary(V) ->
+    case match_prefix(type_doc_prefix(), V) of
+      {ok, _} -> type_doc;
+      _ ->
     case match_prefix(actor_doc_prefix(), V) of
         {ok, _} -> actor_doc;
         _ ->
@@ -510,6 +531,7 @@ accept_format(V) when is_binary(V) ->
                             end
                     end
             end
+    end
     end;
 accept_format(_) -> text.
 
@@ -586,6 +608,11 @@ content_type_for(actor_doc) ->
     <<97,112,112,108,105,99,97,116,105,111,110,47,
       118,110,100,46,102,101,100,45,115,120,46,
       97,99,116,111,114,45,100,111,99>>;
+%% "application/vnd.fed-sx.type-doc" — 31 bytes (host-type fed Step 3).
+content_type_for(type_doc) ->
+    <<97,112,112,108,105,99,97,116,105,111,110,47,
+      118,110,100,46,102,101,100,45,115,120,46,
+      116,121,112,101,45,100,111,99>>;
 content_type_for(_) ->
     content_type_for(text).
 
@@ -714,6 +741,42 @@ kernel_actor_state(_Kernel, Id) ->
         _        -> nil
     end.
 
+%% ── host-type fed Step 3: GET /types/ ──────────────────────
+%%
+%% Serves a TypeRecord the node has cached (its own published types or
+%% types fetched from peers) so a federated peer running
+%% discovery_type_fetch can decode it directly into the shape
+%% peer_types + the object-schema pipeline stage consume. The wire
+%% body is term_codec:encode(TypeRecord) under the
+%% application/vnd.fed-sx.type-doc content type; a cache miss is a 404.
+%%
+%% Cid is the path segment after "/types/" (the type's CID bytes). Cfg
+%% carries `{peer_types, peer_types}` to opt the route into the cache —
+%% absent (or the gen_server down) short-circuits to 404, matching the
+%% kernel_actor_state guard for the actor-doc route. This port can't
+%% dispatch `Mod:Fun` on a variable module, so the registered
+%% `peer_types` atom is hardcoded; the Cfg field flags "no cache wired".
+
+type_doc_response_for(Cid, Cfg) ->
+    case type_record_for(Cfg, Cid) of
+        nil -> not_found_response();
+        TR  -> ok_response(term_codec:encode(TR), type_doc)
+    end.
+
+type_record_for(Cfg, Cid) ->
+    case field(peer_types, Cfg) of
+        nil -> nil;
+        _   ->
+            case erlang:whereis(peer_types) of
+                undefined -> nil;
+                _ ->
+                    case peer_types:lookup(Cid) of
+                        {ok, TR} -> TR;
+                        _        -> nil
+                    end
+            end
+    end.
+
 %% ── Step 4a: per-actor sub-resource stubs ──────────────────────
 %% Per design §16.1 each actor has /outbox /inbox /followers
 %% /following routes. v1 returns text-stub bodies so route resolution
diff --git a/next/tests/discovery_type_fetch.sh b/next/tests/discovery_type_fetch.sh
new file mode 100755
index 00000000..a5b949c9
--- /dev/null
+++ b/next/tests/discovery_type_fetch.sh
@@ -0,0 +1,176 @@
+#!/usr/bin/env bash
+# next/tests/discovery_type_fetch.sh — host-type federation Phase 3.
+#
+# Client side of the type-doc wire: discovery_type_fetch builds the
+# fun/2 closure peer_types:lookup_or_fetch calls on a cache miss. It
+# GETs /types/ with the type-doc Accept header and returns
+# the RAW response bytes (peer_types decodes them via term_codec).
+# Exercised end-to-end against a background python http server that
+# serves hand-crafted term_codec bytes, so we test the wire — not just
+# an in-process call.
+
+set -uo pipefail
+cd "$(git rev-parse --show-toplevel)"
+
+SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
+if [ ! -x "$SX_SERVER" ]; then
+  SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
+fi
+if [ ! -x "$SX_SERVER" ]; then
+  echo "ERROR: sx_server.exe not found." >&2
+  exit 1
+fi
+
+VERBOSE="${1:-}"
+PASS=0; FAIL=0; ERRORS=""
+
+# ── live stub server ─────────────────────────────────────────
+# GET /types/bafy1 -> 200 with term_codec-encoded TypeRecord
+#   TR = [{name, <<"Post">>}, {instance_type, <<"Note">>}]
+# GET anything else -> 404
+PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')
+SRVROOT=$(mktemp -d)
+PYSRV="$SRVROOT/srv.py"
+cat > "$PYSRV" <<'PY'
+import sys, http.server, socketserver
+
+PORT = int(sys.argv[1])
+
+# term_codec encoding (mirror of next/kernel/term_codec.erl).
+def enc_atom(s):
+    b = s.encode()
+    return f"a{len(b)}:".encode() + b
+def enc_bin(b):
+    return f"b{len(b)}:".encode() + b
+def enc_tuple(items):
+    return f"t{len(items)}:".encode() + b"".join(items)
+def enc_list(items):
+    return f"l{len(items)}:".encode() + b"".join(items)
+
+# [{name, <<"Post">>}, {instance_type, <<"Note">>}]
+TYPEDOC = enc_list([
+    enc_tuple([enc_atom("name"), enc_bin(b"Post")]),
+    enc_tuple([enc_atom("instance_type"), enc_bin(b"Note")]),
+])
+
+class H(http.server.BaseHTTPRequestHandler):
+    def do_GET(self):
+        if self.path == "/types/bafy1":
+            self.send_response(200)
+            self.send_header('content-type','application/vnd.fed-sx.type-doc')
+            self.send_header('content-length', str(len(TYPEDOC)))
+            self.end_headers()
+            self.wfile.write(TYPEDOC)
+        else:
+            self.send_response(404); self.end_headers(); self.wfile.write(b'not found')
+    def log_message(self, fmt, *args): pass
+
+with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+    srv.serve_forever()
+PY
+python3 "$PYSRV" "$PORT" >/dev/null 2>&1 &
+SRV_PID=$!
+TMPFILE=$(mktemp)
+trap "rm -rf $SRVROOT $TMPFILE; kill $SRV_PID 2>/dev/null || true" EXIT
+for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
+  if curl -fsS "http://127.0.0.1:$PORT/types/bafy1" >/dev/null 2>&1; then break; fi
+  sleep 0.2
+done
+
+bytes_of() { python3 -c "import sys; print(','.join(str(b) for b in sys.argv[1].encode()))" "$1"; }
+URL_BASE_BYTES=$(bytes_of "http://127.0.0.1:$PORT")
+
+cat > "$TMPFILE" <<'EPOCHS'
+(epoch 1)
+(load "lib/erlang/tokenizer.sx")
+(load "lib/erlang/parser.sx")
+(load "lib/erlang/parser-core.sx")
+(load "lib/erlang/parser-expr.sx")
+(load "lib/erlang/parser-module.sx")
+(load "lib/erlang/transpile.sx")
+(load "lib/erlang/runtime.sx")
+(load "lib/erlang/vm/dispatcher.sx")
+(epoch 2)
+(eval "(er-load-gen-server!)")
+(epoch 3)
+(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
+(epoch 4)
+(eval "(get (erlang-load-module (file-read \"next/kernel/peer_types.erl\")) :name)")
+(epoch 5)
+(eval "(get (erlang-load-module (file-read \"next/kernel/discovery_type_fetch.erl\")) :name)")
+
+;; accept_header is the 31-byte type-doc MIME
+(epoch 10)
+(eval "(get (erlang-eval-ast \"byte_size(discovery_type_fetch:accept_header()) =:= 31\") :name)")
+
+;; type_doc_url builds /types/bafy1
+(epoch 11)
+(eval "(get (erlang-eval-ast \"U = discovery_type_fetch:type_doc_url(<<__URL_BASE__>>, <<98,97,102,121,49>>), U =:= <<__URL_BASE__,47,116,121,112,101,115,47,98,97,102,121,49>>\") :name)")
+
+;; resolve_type_url via the static type_url proplist
+(epoch 12)
+(eval "(get (erlang-eval-ast \"discovery_type_fetch:resolve_type_url(<<98,97,102,121,49>>, [{type_url, [{<<98,97,102,121,49>>, <<__URL_BASE__>>}]}]) =:= {ok, <<__URL_BASE__>>}\") :name)")
+
+;; fetch live -> {ok, Bytes} that decode to the TypeRecord
+(epoch 13)
+(eval "(get (erlang-eval-ast \"R = discovery_type_fetch:fetch(<<__URL_BASE__,47,116,121,112,101,115,47,98,97,102,121,49>>, []), case R of {ok, B} -> {ok, TR, _} = term_codec:decode(B), TR =:= [{name, <<80,111,115,116>>}, {instance_type, <<78,111,116,101>>}]; _ -> false end\") :name)")
+
+;; closure from make_fetch_fn/0 dispatches and returns raw bytes
+(epoch 14)
+(eval "(get (erlang-eval-ast \"Fn = discovery_type_fetch:make_fetch_fn(), Cfg = [{type_url, [{<<98,97,102,121,49>>, <<__URL_BASE__>>}]}], case Fn(<<98,97,102,121,49>>, Cfg) of {ok, B} -> {ok, TR, _} = term_codec:decode(B), TR =:= [{name, <<80,111,115,116>>}, {instance_type, <<78,111,116,101>>}]; _ -> false end\") :name)")
+
+;; closure with no resolver -> {error, no_type_url}
+(epoch 15)
+(eval "(get (erlang-eval-ast \"Fn = discovery_type_fetch:make_fetch_fn(), case Fn(<<98,97,102,121,49>>, []) of {error, no_type_url} -> true; _ -> false end\") :name)")
+
+;; fetch on an unknown cid path -> {error, {status, 404}}
+(epoch 16)
+(eval "(get (erlang-eval-ast \"R = discovery_type_fetch:fetch(<<__URL_BASE__,47,116,121,112,101,115,47,122,122,122>>, []), case R of {error, {status, 404}} -> true; _ -> false end\") :name)")
+
+;; end-to-end: peer_types:lookup_or_fetch uses the closure, decodes,
+;; and writes the TypeRecord into the cache
+(epoch 17)
+(eval "(get (erlang-eval-ast \"Fn = discovery_type_fetch:make_fetch_fn(), Cfg = [{type_fetch_fn, Fn}, {type_url, [{<<98,97,102,121,49>>, <<__URL_BASE__>>}]}], case peer_types:lookup_or_fetch(<<98,97,102,121,49>>, Cfg, peer_types:new()) of {ok, TR, S} -> TR =:= [{name, <<80,111,115,116>>}, {instance_type, <<78,111,116,101>>}] andalso peer_types:types(S) =:= [<<98,97,102,121,49>>]; _ -> false end\") :name)")
+EPOCHS
+
+sed -i "s|__URL_BASE__|${URL_BASE_BYTES}|g" "$TMPFILE"
+
+OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
+
+check() {
+  local epoch="$1" desc="$2" expected="$3"
+  local actual
+  actual=$(echo "$OUTPUT" | awk -v e="$epoch" '
+    $0 ~ "^\\(ok-len " e " " { getline; print; exit }
+    $0 ~ "^\\(ok " e " "     { print; exit }
+    $0 ~ "^\\(error " e " "  { print; exit }
+  ')
+  [ -z "$actual" ] && actual=""
+  if echo "$actual" | grep -qF -- "$expected"; then
+    PASS=$((PASS+1))
+    [ "$VERBOSE" = "-v" ] && echo "  ok $desc"
+  else
+    FAIL=$((FAIL+1))
+    ERRORS+="  FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual
+"
+  fi
+}
+
+check  5  "discovery_type_fetch loaded"          "discovery_type_fetch"
+check 10  "accept_header is 31-byte type-doc"    "true"
+check 11  "type_doc_url builds /types/"     "true"
+check 12  "resolve_type_url via type_url map"    "true"
+check 13  "fetch live -> raw bytes decode to TR" "true"
+check 14  "closure -> raw bytes decode to TR"    "true"
+check 15  "closure no resolver -> no_type_url"   "true"
+check 16  "fetch 404 path -> {status, 404}"      "true"
+check 17  "lookup_or_fetch caches fetched TR"    "true"
+
+TOTAL=$((PASS+FAIL))
+if [ $FAIL -eq 0 ]; then
+  echo "ok $PASS/$TOTAL next/tests/discovery_type_fetch.sh passed"
+else
+  echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
+  echo "$ERRORS"
+fi
+[ $FAIL -eq 0 ]
diff --git a/next/tests/peer_types_route.sh b/next/tests/peer_types_route.sh
new file mode 100755
index 00000000..cd845788
--- /dev/null
+++ b/next/tests/peer_types_route.sh
@@ -0,0 +1,140 @@
+#!/usr/bin/env bash
+# next/tests/peer_types_route.sh — host-type federation Phase 3.
+#
+# Server side of the type-doc wire: http_server serves
+#   GET /types/   Accept: application/vnd.fed-sx.type-doc
+# as the term_codec-encoded TypeRecord pulled from the peer_types
+# cache; 404 if the cid isn't cached. Exercised via http_server:route
+# in-process (the established pattern — see http_actors.sh) so the
+# route resolution + content negotiation are tested without a live
+# socket. The peer_types gen_server holds the cache across epochs.
+
+set -uo pipefail
+cd "$(git rev-parse --show-toplevel)"
+
+SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
+if [ ! -x "$SX_SERVER" ]; then
+  SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
+fi
+if [ ! -x "$SX_SERVER" ]; then
+  echo "ERROR: sx_server.exe not found." >&2
+  exit 1
+fi
+
+VERBOSE="${1:-}"
+PASS=0; FAIL=0; ERRORS=""
+TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT
+
+# TR is the served TypeRecord, Cid its key. AccV is the type-doc
+# Accept header value, CT the content-type key. Cfg opts the route
+# into the peer_types cache. ReqHit / ReqMiss / ReqEmpty / ReqPost
+# vary the request line.
+SETUP='TR = [{name, <<80,111,115,116>>}, {instance_type, <<78,111,116,101>>}], Cid = <<98,97,102,121,49>>, peer_types:start_link(), peer_types:put(Cid, TR), AcK = <<97,99,99,101,112,116>>, AcV = <<97,112,112,108,105,99,97,116,105,111,110,47,118,110,100,46,102,101,100,45,115,120,46,116,121,112,101,45,100,111,99>>, Hs = [{AcK, AcV}], Cfg = [{peer_types, peer_types}],'
+
+cat > "$TMPFILE" <>) =:= type_doc\") :name)")
+;; type_doc content type is 31 bytes
+(epoch 11)
+(eval "(get (erlang-eval-ast \"byte_size(http_server:content_type_for(type_doc)) =:= 31\") :name)")
+;; types_prefix is "/types/" — 7 bytes
+(epoch 12)
+(eval "(get (erlang-eval-ast \"byte_size(http_server:types_prefix()) =:= 7\") :name)")
+
+;; ── GET /types/ ───────────────────────────────────────
+;; cache hit -> 200
+(epoch 20)
+(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47,116,121,112,101,115,47,98,97,102,121,49>>}, {headers, Hs}], R = http_server:route(Req, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 200\") :name)")
+;; body decodes back to the stored TypeRecord
+(epoch 21)
+(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47,116,121,112,101,115,47,98,97,102,121,49>>}, {headers, Hs}], R = http_server:route(Req, Cfg), {ok, B} = envelope:get_field(body, R), {ok, DTR, _} = term_codec:decode(B), DTR =:= TR\") :name)")
+;; response carries the type-doc content type
+(epoch 22)
+(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47,116,121,112,101,115,47,98,97,102,121,49>>}, {headers, Hs}], R = http_server:route(Req, Cfg), {ok, Hdrs} = envelope:get_field(headers, R), {_CTK, CTV} = hd(Hdrs), CTV =:= http_server:content_type_for(type_doc)\") :name)")
+;; type_doc_response_for/2 direct: known cid -> 200
+(epoch 23)
+(eval "(get (erlang-eval-ast \"${SETUP} R = http_server:type_doc_response_for(Cid, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 200\") :name)")
+
+;; ── misses + wrong method ──────────────────────────────────
+;; unknown cid -> 404
+(epoch 30)
+(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47,116,121,112,101,115,47,122,122,122>>}, {headers, Hs}], R = http_server:route(Req, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 404\") :name)")
+;; empty cid (GET /types/) -> 404
+(epoch 31)
+(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47,116,121,112,101,115,47>>}, {headers, Hs}], R = http_server:route(Req, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 404\") :name)")
+;; no peer_types cfg -> 404 even for a known cid
+(epoch 32)
+(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47,116,121,112,101,115,47,98,97,102,121,49>>}, {headers, Hs}], R = http_server:route(Req, []), {ok, S} = envelope:get_field(status, R), S =:= 404\") :name)")
+;; POST /types/ -> 404 (only GET serves type docs)
+(epoch 33)
+(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, <<47,116,121,112,101,115,47,98,97,102,121,49>>}, {headers, Hs}], R = http_server:route(Req, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 404\") :name)")
+;; existing routes intact: GET / still 200
+(epoch 34)
+(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47>>}], R = http_server:route(Req, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 200\") :name)")
+EPOCHS
+
+OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
+
+check() {
+  local epoch="$1" desc="$2" expected="$3"
+  local actual
+  actual=$(echo "$OUTPUT" | awk -v e="$epoch" '
+    $0 ~ "^\\(ok-len " e " " { getline; print; exit }
+    $0 ~ "^\\(ok " e " "     { print; exit }
+    $0 ~ "^\\(error " e " "  { print; exit }
+  ')
+  [ -z "$actual" ] && actual=""
+  if echo "$actual" | grep -qF -- "$expected"; then
+    PASS=$((PASS+1))
+    [ "$VERBOSE" = "-v" ] && echo "  ok $desc"
+  else
+    FAIL=$((FAIL+1))
+    ERRORS+="  FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual
+"
+  fi
+}
+
+check  6  "http_server module loaded"        "http_server"
+check 10  "Accept type-doc -> type_doc"      "true"
+check 11  "type_doc content type = 31 bytes" "true"
+check 12  "types_prefix = 7 bytes"           "true"
+check 20  "GET /types/ hit -> 200"      "true"
+check 21  "body decodes to TypeRecord"       "true"
+check 22  "response is type-doc content type" "true"
+check 23  "type_doc_response_for hit -> 200" "true"
+check 30  "unknown cid -> 404"               "true"
+check 31  "empty cid -> 404"                 "true"
+check 32  "no peer_types cfg -> 404"         "true"
+check 33  "POST /types/ -> 404"         "true"
+check 34  "existing GET / route intact"      "true"
+
+TOTAL=$((PASS+FAIL))
+if [ $FAIL -eq 0 ]; then
+  echo "ok $PASS/$TOTAL next/tests/peer_types_route.sh passed"
+else
+  echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
+  echo "$ERRORS"
+fi
+[ $FAIL -eq 0 ]