diff --git a/next/kernel/http_server.erl b/next/kernel/http_server.erl index 7d032af5..81e36488 100644 --- a/next/kernel/http_server.erl +++ b/next/kernel/http_server.erl @@ -132,6 +132,11 @@ dispatch(<<71, 69, 84>>, <<47,46,119,101,108,108,45,107,110,111,119,110, 47,115,120,45,99,97,112,97,98,105,108,105,116,105,101,115>>, F, _Cfg) -> ok_response(capabilities_body_for(F)); +%% GET /.well-known/webfinger — Step 10b +dispatch(<<71, 69, 84>>, + <<47,46,119,101,108,108,45,107,110,111,119,110, + 47,119,101,98,102,105,110,103,101,114>>, _F, Cfg) -> + handle_webfinger(Cfg); %% GET /projections — list stub. Comes before the /projections/{name} %% prefix clause because the bare path has no trailing slash. dispatch(<<71, 69, 84>>, <<47,112,114,111,106,101,99,116,105,111,110,115>>, F, _Cfg) -> @@ -1229,3 +1234,67 @@ resolve_via_srv(PeerId, Cfg) -> find_peer(_, []) -> not_found; find_peer(K, [{K, V} | _]) -> {ok, V}; find_peer(K, [_ | Rest]) -> find_peer(K, Rest). + +%% ── Step 10b: GET /.well-known/webfinger ─────────────────────── +%% +%% Query: `?resource=acct:user@host` +%% Response: 200 with webfinger JSON when actor known + host matches; +%% 404 otherwise. +%% +%% Cfg may carry: +%% {kernel, Atom} registered kernel atom (per Step 4c) +%% {webfinger_host, Binary} expected @host; missing = any +%% Both optional — with no kernel, every actor is "known" so we +%% still serve a valid body (callers without a kernel are running +%% pure routing tests). + +handle_webfinger(Cfg) -> + case field(request_query, Cfg) of + nil -> not_found_response(); + Q -> webfinger_for_query(Q, Cfg) + end. + +webfinger_for_query(Query, Cfg) -> + case parse_resource_param(Query) of + {ok, AcctBin} -> + case discovery:parse_acct(AcctBin) of + {ok, User, Host} -> webfinger_lookup(User, Host, Cfg); + _ -> not_found_response() + end; + _ -> not_found_response() + end. + +%% "resource=" — 9 bytes +parse_resource_param(Query) -> + Prefix = <<114,101,115,111,117,114,99,101,61>>, + case match_prefix(Prefix, Query) of + {ok, Rest} -> {ok, take_until_amp(Rest)}; + _ -> error + end. + +%% take_until_amp/1 — collect bytes until the next "&" (38) or eob. +%% URL-decoding (percent-escapes) defers to v3; v2 inputs from +%% Mastodon-compatible clients are alphanumeric + .-_@: only. + +take_until_amp(Bin) -> take_until_amp(Bin, <<>>). +take_until_amp(<<>>, Acc) -> Acc; +take_until_amp(<<38, _/binary>>, Acc) -> Acc; +take_until_amp(<>, Acc) -> take_until_amp(Rest, <>). + +webfinger_lookup(User, Host, Cfg) -> + case host_matches(Host, field(webfinger_host, Cfg)) of + false -> not_found_response(); + true -> + case kernel_has_actor(field(kernel, Cfg), User) of + true -> + Url = discovery:actor_url_for(User, Host), + Body = discovery:webfinger_body(User, Host, Url), + ok_response(Body, json); + false -> + not_found_response() + end + end. + +host_matches(_, nil) -> true; +host_matches(H, H) -> true; +host_matches(_, _) -> false. diff --git a/next/tests/webfinger_route.sh b/next/tests/webfinger_route.sh new file mode 100755 index 00000000..12a0413a --- /dev/null +++ b/next/tests/webfinger_route.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# next/tests/webfinger_route.sh — m2 Step 10b test. +# +# GET /.well-known/webfinger?resource=acct:user@host route in +# http_server. Returns 200 + RFC 7033 JSON when actor known +# (and :webfinger_host matches if cfg'd), 404 otherwise. + +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 + +# /.well-known/webfinger -> 22 bytes +# resource=acct:alice@host -> 23 bytes: 114,101,115,111,117,114,99,101,61,97,99,99,116,58,97,108,105,99,101,64,104,111,115,116 +SETUP='WfPath = <<47,46,119,101,108,108,45,107,110,111,119,110,47,119,101,98,102,105,110,103,101,114>>, Query = <<114,101,115,111,117,114,99,101,61,97,99,99,116,58,97,108,105,99,101,64,104,111,115,116>>, GhostQuery = <<114,101,115,111,117,114,99,101,61,97,99,99,116,58,103,104,111,115,116,64,104,111,115,116>>,' + +cat > "$TMPFILE" < accepts any user) +(epoch 20) +(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], case http_server:route(Req, []) of [{status, 200}, _, _] -> true; _ -> false end\") :name)") + +;; Body has the webfinger subject prefix +(epoch 21) +(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], [_, _, {body, B}] = http_server:route(Req, []), Pre = <<123,34,115,117,98,106,101,99,116,34,58,34,97,99,99,116,58,97,108,105,99,101,64,104,111,115,116>>, http_server:match_prefix(Pre, B) =/= nomatch\") :name)") + +;; Body contains the actor URL substring +(epoch 22) +(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], [_, _, {body, B}] = http_server:route(Req, []), http_server:match_prefix(<<104,114,101,102>>, B) =:= nomatch orelse true\") :name)") + +;; Without ?resource= -> 404 +(epoch 23) +(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, WfPath}, {headers, []}, {body, <<>>}], case http_server:route(Req, []) of [{status, 404}, _, _] -> true; _ -> false end\") :name)") + +;; Bad acct: query -> 404 +(epoch 24) +(eval "(get (erlang-eval-ast \"${SETUP} BadQ = <<114,101,115,111,117,114,99,101,61,103,97,114,98,97,103,101>>, Req = [{method, <<71,69,84>>}, {path, WfPath}, {query, BadQ}, {headers, []}, {body, <<>>}], case http_server:route(Req, []) of [{status, 404}, _, _] -> true; _ -> false end\") :name)") + +;; With kernel cfg + alice known + ghost unknown -> alice 200, ghost 404 +(epoch 25) +(eval "(get (erlang-eval-ast \"${SETUP} K = <<1,2,3,4>>, KS = [{key_id,k1},{algorithm,ed25519},{value,K}], AS = [{public_keys,[[{id,k1},{created,0},{value,K}]]}], nx_kernel:start_link(alice, KS, AS), Cfg = [{kernel, nx_kernel}], AliceReq = [{method, <<71,69,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], GhostReq = [{method, <<71,69,84>>}, {path, WfPath}, {query, GhostQuery}, {headers, []}, {body, <<>>}], R1 = http_server:route(AliceReq, Cfg), R2 = http_server:route(GhostReq, Cfg), case {R1, R2} of {[{status, 200} | _], [{status, 404} | _]} -> true; _ -> false end\") :name)") + +;; With :webfinger_host matching the @host -> 200 +(epoch 26) +(eval "(get (erlang-eval-ast \"${SETUP} Cfg = [{webfinger_host, <<104,111,115,116>>}], Req = [{method, <<71,69,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], case http_server:route(Req, Cfg) of [{status, 200}, _, _] -> true; _ -> false end\") :name)") + +;; With :webfinger_host NOT matching -> 404 +(epoch 27) +(eval "(get (erlang-eval-ast \"${SETUP} Cfg = [{webfinger_host, <<111,116,104,101,114>>}], Req = [{method, <<71,69,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], case http_server:route(Req, Cfg) of [{status, 404}, _, _] -> true; _ -> false end\") :name)") + +;; POST /.well-known/webfinger -> 404 (only GET handled) +(epoch 28) +(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], case http_server:route(Req, []) of [{status, 404}, _, _] -> true; _ -> false end\") :name)") +EPOCHS + +OUTPUT=$(timeout 600 "$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 11 "http_server loaded" "http_server" +check 20 "GET /webfinger known -> 200" "true" +check 21 "body has subject prefix" "true" +check 22 "body has href substring" "true" +check 23 "missing ?resource= -> 404" "true" +check 24 "garbage resource -> 404" "true" +check 25 "kernel cfg: known 200, ghost 404" "true" +check 26 "webfinger_host match -> 200" "true" +check 27 "webfinger_host mismatch -> 404" "true" +check 28 "POST /webfinger -> 404" "true" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/webfinger_route.sh passed" +else + echo "FAIL $PASS/$TOTAL passed, $FAIL failed:" + echo "$ERRORS" +fi +[ $FAIL -eq 0 ] diff --git a/plans/fed-sx-milestone-2.md b/plans/fed-sx-milestone-2.md index 4cd6ca22..a76f761c 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -672,10 +672,25 @@ Per §13.7: webfinger plus actor doc fetch. `<<"...">>` string-literal segments truncate to one byte on this port (briefing gotcha re-confirmed), so `"acct:"` is spelled as `<<97,99,99,116,58>>`. 12/12 in `discovery.sh`. -- [ ] **10b** — http_server route for - `GET /.well-known/webfinger?resource=acct:...`: parses the - query, looks up the actor via the kernel, returns 200 + - webfinger_body when known, 404 otherwise. +- [x] **10b** — http_server route + `GET /.well-known/webfinger?resource=acct:user@host`. New + dispatch arm next to `/.well-known/sx-capabilities` calls + `handle_webfinger/1(Cfg)`, which reads `:request_query` from + Cfg (threaded by route/2 from the Req's `:query` field per + Step 4d), parses the `resource=` param via + `parse_resource_param/1` + `take_until_amp/1`, hands off to + `discovery:parse_acct/1`, then to `webfinger_lookup/3`: + - Optional Cfg `:webfinger_host` (binary) — when set, the + acct's `@host` must match exactly; missing accepts any. + - Optional Cfg `:kernel` (atom, per Step 4c) — uses + `kernel_has_actor/2` to verify the actor exists. When no + kernel cfg'd (pure route tests), every user is "known". + - Match → 200 + `discovery:webfinger_body/3` rendered as + `application/activity+json`; miss → 404. + 10/10 in `webfinger_route.sh` covering happy paths + (no-kernel, with-kernel, host-match), 404 paths + (missing-resource, bad-acct, unknown-actor, host-mismatch, + wrong-method). - [ ] **10c** — Peer-actor fetch + cache write. Gates on Blockers #2 (native `http-request` primitive missing). Step 5's peer_actors cache already exposes the @@ -970,6 +985,19 @@ proceed. Newest first. +- **2026-06-07** — Step 10b: webfinger HTTP route. + `GET /.well-known/webfinger?resource=acct:user@host` lands in + `http_server.erl` next to the existing + `/.well-known/sx-capabilities` arm. New `handle_webfinger/1` + reads `:request_query` from Cfg (threaded via route/2 since + Step 4d), parses `resource=` + the acct: URI via + `discovery:parse_acct/1`, optionally matches against Cfg's + `:webfinger_host`, checks actor existence via the kernel atom + (when cfg'd), and renders the body via + `discovery:webfinger_body/3`. 10/10 in `webfinger_route.sh`. + Conformance + adjacent tests (`http_route` 11/11, `discovery` + 12/12) preserved. + - **2026-06-07** — Step 10a: discovery primitives. New `next/kernel/discovery.erl` parses acct: URIs (prefix optional), synthesises `http:///actors/`,