fed-sx-m2: Step 4b — token -> ActorId map + 8 new tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 19s

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).
This commit is contained in:
2026-06-06 14:31:27 +00:00
parent 0b8772ec69
commit 271632c923
3 changed files with 128 additions and 23 deletions

View File

@@ -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 ──────────────────────────────
%%

View File

@@ -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

View File

@@ -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/<id>/`
return 404. Bare `/actors/<id>` 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/<id>/...`
paths route on the sub-segment (`outbox`, `inbox`, `followers`,