From 271632c923065ac7460dbab2ca14ce79e597646b Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 14:31:27 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m2:=20Step=204b=20=E2=80=94=20token=20->?= =?UTF-8?q?=20ActorId=20map=20+=208=20new=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /activity now routes through nx_kernel:publish_to/2 when the bearer token resolves to an explicit ActorId via Cfg's :tokens proplist: Cfg = [{tokens, [{<<"alice-token">>, alice}, {<<"bob-token">>, bob}]}] resolve_token/2 returns {ok, ActorId} on a :tokens hit. On a miss it falls back to the M1 :publish_token single-token field — match returns {ok, legacy}, routing through nx_kernel:publish/1 (which fans out to bucket 0) so every M1 test continues to pass. handle_post_activity threads the resolved ActorRef to publish_if_kernel/3 which dispatches publish_to/2 for explicit actor ids and publish/1 for the legacy atom. The no-kernel auth-only path (which preserves the post_activity_response_for stub for unit-style tests of http_server alone) is unchanged. Dead expected_token/1 helper removed (was only called by the old check_bearer arm that resolve_token replaces). 8 new cases in next/tests/http_multi_actor.sh (25/25 total): - two-actor Cfg, Alice token -> 200 with cid: - Alice token publishes to alice (log_tip alice=1, bob=0) - Bob token publishes to bob (log_tip alice=0, bob=1) - interleaved Alice + Bob + Alice -> {2, 1} - unknown token + no :publish_token -> 401 - legacy :publish_token still works (M1 back-compat) - tokens map AND legacy :publish_token coexist (each resolves to its own actor; legacy lands on alice bucket via publish/1) - no kernel + valid :tokens entry -> auth-only stub 200 Conformance 761/761. 116/116 across 10 Step-4-adjacent suites (http_multi_actor, http_route, http_publish, http_post_format, http_marshal, http_publish_fold, http_listen_bif, http_server_start, nx_kernel_multi, bootstrap_start, actor_lifecycle). --- next/kernel/http_server.erl | 60 ++++++++++++++++++++++++---------- next/tests/http_multi_actor.sh | 57 ++++++++++++++++++++++++++++++++ plans/fed-sx-milestone-2.md | 34 +++++++++++++++---- 3 files changed, 128 insertions(+), 23 deletions(-) diff --git a/next/kernel/http_server.erl b/next/kernel/http_server.erl index a629aac1..e322474e 100644 --- a/next/kernel/http_server.erl +++ b/next/kernel/http_server.erl @@ -302,29 +302,38 @@ post_activity_response() -> handle_post_activity(Req, Cfg) -> case check_bearer(Req, Cfg) of - ok -> + {ok, ActorRef} -> F = accept_format_from(Req), - publish_if_kernel(Req, F); + publish_if_kernel(Req, F, ActorRef); {error, _} -> unauthorized_response() end. -%% publish_if_kernel/2 — if the nx_kernel gen_server is registered, +%% publish_if_kernel/3 — if the nx_kernel gen_server is registered, %% delegate the publish there and translate the result. Otherwise %% keep the stub response so the auth-only tests stay green without %% having to spin up a kernel process. Format threads through to %% both stub and CID responses so the Content-Type matches what %% the client asked for via Accept. -publish_if_kernel(Req, F) -> +%% +%% ActorRef is either an explicit ActorId atom (Step 4b token map +%% resolution: route through nx_kernel:publish_to/2) or the atom +%% `legacy` from a single :publish_token Cfg back-compat (route +%% through nx_kernel:publish/1, which fans out to bucket 0). +publish_if_kernel(Req, F, ActorRef) -> case erlang:whereis(nx_kernel) of undefined -> post_activity_response_for(F); _Pid -> Body = field(body, Req), Request = [{type, create}, {object, Body}], - case nx_kernel:publish(Request) of - {ok, Result} -> - case envelope:get_field(cid, Result) of + Result = case ActorRef of + legacy -> nx_kernel:publish(Request); + _ -> nx_kernel:publish_to(ActorRef, Request) + end, + case Result of + {ok, R} -> + case envelope:get_field(cid, R) of {ok, Cid} -> cid_response_for(Cid, F); _ -> post_activity_response_for(F) end; @@ -348,14 +357,36 @@ validation_failed_response() -> check_bearer(Req, Cfg) -> case bearer_token(Req) of - {ok, Got} -> - case expected_token(Cfg) of - {ok, Want} when Got =:= Want -> ok; - _ -> {error, bad_token} - end; + {ok, Got} -> resolve_token(Got, Cfg); not_found -> {error, no_auth} end. +%% resolve_token/2 — map a bearer token to either an explicit +%% ActorId (via Cfg's :tokens proplist) or the back-compat `legacy` +%% atom (via the M1 single-actor :publish_token). The :tokens map +%% takes precedence; if both are configured, :publish_token is only +%% consulted when the token isn't present in :tokens. +resolve_token(Got, Cfg) -> + case field(tokens, Cfg) of + nil -> resolve_legacy_token(Got, Cfg); + Tokens -> + case lookup_token(Got, Tokens) of + {ok, ActorId} -> {ok, ActorId}; + not_found -> resolve_legacy_token(Got, Cfg) + end + end. + +resolve_legacy_token(Got, Cfg) -> + case field(publish_token, Cfg) of + nil -> {error, no_token_match}; + Want when Got =:= Want -> {ok, legacy}; + _ -> {error, bad_token} + end. + +lookup_token(_, []) -> not_found; +lookup_token(K, [{K, V} | _]) -> {ok, V}; +lookup_token(K, [_ | Rest]) -> lookup_token(K, Rest). + %% Look up the Authorization header, strip "Bearer ", return token. bearer_token(Req) -> case field(headers, Req) of @@ -383,11 +414,6 @@ strip_bearer(V) -> _ -> not_found end. -expected_token(Cfg) -> - case field(publish_token, Cfg) of - nil -> not_found; - T -> {ok, T} - end. %% ── Step 8d: Accept-header parsing ────────────────────────────── %% diff --git a/next/tests/http_multi_actor.sh b/next/tests/http_multi_actor.sh index bc8f7de5..bd7597a8 100755 --- a/next/tests/http_multi_actor.sh +++ b/next/tests/http_multi_actor.sh @@ -40,6 +40,18 @@ cat > "$TMPFILE" <<'EPOCHS' (load "lib/erlang/vm/dispatcher.sx") (epoch 2) (eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)") +(epoch 3) +(eval "(er-load-gen-server!)") +(epoch 4) +(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)") +(epoch 5) +(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)") +(epoch 6) +(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)") +(epoch 7) +(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)") +(epoch 8) +(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)") ;; split_first_slash sanity (epoch 10) @@ -100,6 +112,43 @@ cat > "$TMPFILE" <<'EPOCHS' ;; Accept: application/json on /actors/alice/followers -> JSON stub (epoch 32) (eval "(get (erlang-eval-ast \"AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>, Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,102,111,108,108,111,119,101,114,115>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(Req), [_, _, {body, B}] = R, http_server:match_prefix(<<123,34,102,111,108,108,111,119,101,114,115,34>>, B) =/= nomatch\") :name)") + +;; ── Step 4b: token -> ActorId map ────────────────────────────── +;; Each test inlines start_link + add_actor + Cfg with :tokens +;; proplist mapping per-actor bearer tokens. Tokens look like +;; "alice-token" = <<97,108,105,99,101,45,116,111,107,101,110>> +;; (bytes spelled) and "bob-token" = <<98,111,98,45,116,111,107,101,110>>. + +(epoch 40) +(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, BobTok = <<98,111,98,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}, {BobTok, bob}]}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], R = http_server:route(Req, Cfg), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<99,105,100,58,32>>, B) =/= nomatch; _ -> false end\") :name)") + +;; Alice token publishes to alice's bucket (log_tip alice = 1, bob = 0) +(epoch 41) +(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, BobTok = <<98,111,98,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}, {BobTok, bob}]}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(Req, Cfg), {nx_kernel:log_tip_for(alice), nx_kernel:log_tip_for(bob)} =:= {1, 0}\") :name)") + +;; Bob token publishes to bob's bucket +(epoch 42) +(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, BobTok = <<98,111,98,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, BobAuth = <<66,101,97,114,101,114,32,98,111,98,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}, {BobTok, bob}]}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, BobAuth}]}, {body, <<104,105>>}], http_server:route(Req, Cfg), {nx_kernel:log_tip_for(alice), nx_kernel:log_tip_for(bob)} =:= {0, 1}\") :name)") + +;; Mixed token stream -> independent logs +(epoch 43) +(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, BobTok = <<98,111,98,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, BobAuth = <<66,101,97,114,101,114,32,98,111,98,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}, {BobTok, bob}]}], AliceReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], BobReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, BobAuth}]}, {body, <<104,105>>}], http_server:route(AliceReq, Cfg), http_server:route(BobReq, Cfg), http_server:route(AliceReq, Cfg), {nx_kernel:log_tip_for(alice), nx_kernel:log_tip_for(bob)} =:= {2, 1}\") :name)") + +;; Token not in :tokens map and no :publish_token -> 401 +(epoch 44) +(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, GhostAuth = <<66,101,97,114,101,114,32,103,104,111,115,116>>, Cfg = [{tokens, [{AliceTok, alice}]}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, GhostAuth}]}, {body, <<104,105>>}], case http_server:route(Req, Cfg) of [{status, 401}, _, _] -> true; _ -> false end\") :name)") + +;; Legacy :publish_token still works (M1 back-compat) +(epoch 45) +(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), Tok = <<102,111,111>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AuthVal = <<66,101,97,114,101,114,32,102,111,111>>, Cfg = [{publish_token, Tok}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}]}, {body, <<104,105>>}], R = http_server:route(Req, Cfg), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<99,105,100,58,32>>, B) =/= nomatch; _ -> false end\") :name)") + +;; :tokens takes precedence; legacy :publish_token still resolved on miss +(epoch 46) +(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, LegacyTok = <<102,111,111>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, LegacyAuth = <<66,101,97,114,101,114,32,102,111,111>>, Cfg = [{tokens, [{AliceTok, alice}]}, {publish_token, LegacyTok}], Req1 = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], Req2 = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, LegacyAuth}]}, {body, <<104,105>>}], http_server:route(Req1, Cfg), http_server:route(Req2, Cfg), {nx_kernel:log_tip_for(alice), nx_kernel:log_tip_for(bob)} =:= {2, 0}\") :name)") + +;; Token resolution before kernel is registered -> auth-stub published response +(epoch 47) +(eval "(get (erlang-eval-ast \"AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<>>}], R = http_server:route(Req, Cfg), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<112,117,98,108,105,115,104,101,100>>, B) =/= nomatch; _ -> false end\") :name)") EPOCHS OUTPUT=$(timeout 240 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) @@ -140,6 +189,14 @@ check 29 "outbox body carries actor id" "true" check 30 "outbox JSON content negotiation" "true" check 31 "inbox SX content negotiation" "true" check 32 "followers JSON content negotiation" "true" +check 40 "two-token Cfg + Alice POST -> 200" "true" +check 41 "Alice token publishes to alice" "true" +check 42 "Bob token publishes to bob" "true" +check 43 "interleaved tokens isolate logs" "true" +check 44 "unknown token -> 401" "true" +check 45 "legacy :publish_token still works" "true" +check 46 "tokens map + legacy back-compat" "true" +check 47 "no kernel + token map -> stub 200" "true" TOTAL=$((PASS+FAIL)) if [ $FAIL -eq 0 ]; then diff --git a/plans/fed-sx-milestone-2.md b/plans/fed-sx-milestone-2.md index 85f7de61..be78c330 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -272,12 +272,19 @@ token; the token now maps to an `:actor_id` rather than a fixed `alice`. `accepted_response/1`). Unknown sub-paths under `/actors//` return 404. Bare `/actors/` keeps the M1 `actor_doc_response_for` arm. 17 cases in `http_multi_actor.sh`. -- [ ] **4b** — Token → ActorId map. Cfg's `:publish_token` becomes - `:tokens` (proplist `[{Token, ActorId}, ...]`). POST /activity - resolves the bearer token to an actor id and routes through - `nx_kernel:publish_to/2` instead of `publish/1`. Multi-token - Cfg keeps the M1 `:publish_token` single-token field as a - back-compat alias for one token mapping to `alice`. +- [x] **4b** — Token → ActorId map. New `resolve_token/2` reads + `:tokens` from Cfg (proplist `[{Token, ActorId}, ...]`) and + returns `{ok, ActorId}` on match. Falls back to the M1 + `:publish_token` single-token field on miss (returns + `{ok, legacy}`, route through `nx_kernel:publish/1` to bucket 0 + unchanged). Cfg with both fields: `:tokens` wins for matched + tokens; `:publish_token` only consulted on `:tokens` miss. + `handle_post_activity` now threads the resolved `ActorRef` to + `publish_if_kernel/3` which dispatches `publish_to/2` for + explicit actor ids and `publish/1` for the `legacy` atom. + No-kernel auth-only path unchanged. The dead M1 + `expected_token/1` helper is gone. 8 new cases in + `http_multi_actor.sh` (25/25 total). - [ ] **4c** — `http_server:route/3(Req, Cfg, Kernel)` — the Cfg carries opaque `:kernel` reference (or accepts the registered `nx_kernel` atom) so per-actor handlers can call @@ -731,6 +738,21 @@ proceed. Newest first. +- **2026-06-06** — Step 4b: token -> ActorId map. Cfg's `:tokens` + proplist (`[{Token, ActorId}, ...]`) maps bearer tokens to + per-actor publishers. `handle_post_activity` threads the + resolved `ActorRef` to `publish_if_kernel/3` which calls + `nx_kernel:publish_to/2` for explicit actor ids and `publish/1` + for the back-compat `legacy` atom (M1's `:publish_token` + single-token field still works as-is). When both fields are + present, `:tokens` takes precedence; `:publish_token` is the + fallback on miss. Dead `expected_token/1` helper removed. 8 + new cases in `http_multi_actor.sh` (25/25 total) covering + two-actor token routing, log-tip isolation, interleaved + publishes, bad-token 401, back-compat coexistence, no-kernel + stub path. Conformance 761/761 preserved. 116/116 across 10 + Step-4-adjacent suites. + - **2026-06-06** — Step 4a: per-actor HTTP sub-paths. New `split_first_slash/1` helper lets GET / POST `/actors//...` paths route on the sub-segment (`outbox`, `inbox`, `followers`,