diff --git a/next/kernel/http_server.erl b/next/kernel/http_server.erl index 61a45145..48dadfdf 100644 --- a/next/kernel/http_server.erl +++ b/next/kernel/http_server.erl @@ -69,6 +69,7 @@ route(Req, Cfg) -> M = field(method, Req), P = field(path, Req), F = accept_format_from(Req), + Cfg1 = with_request_query(Req, Cfg), case {M, P} of {<<80,79,83,84>>, <<47,97,99,116,105,118,105,116,121>>} -> handle_post_activity(Req, Cfg); @@ -77,7 +78,16 @@ route(Req, Cfg) -> 47,115,120,45,99,97,112,97,98,105,108,105,116,105,101,115>>} -> ok_response(capabilities_body_for(F)); _ -> - dispatch(M, P, F, Cfg) + dispatch(M, P, F, Cfg1) + end. + +%% with_request_query/2 — bake the Req's :query binary into Cfg as +%% `{request_query, Q}` so sub-resource handlers can parse `?page=N` +%% etc without taking the Req as an extra argument. +with_request_query(Req, Cfg) -> + case field(query, Req) of + nil -> Cfg; + Q -> [{request_query, Q} | Cfg] end. %% route/3 — Step 4c convenience entry. Kernel is an opaque @@ -680,14 +690,101 @@ actor_outbox_response_for(Id, F, Cfg) -> nil -> actor_outbox_response_for(Id, F); Kernel -> - case kernel_log_tip(Kernel, Id) of + case kernel_actor_log_data(Kernel, Id) of nil -> actor_outbox_response_for(Id, F); - Tip -> - actor_outbox_with_tip_response_for(Id, F, Tip) + {Tip, Entries} -> + Page = parse_page(field(request_query, Cfg)), + Slice = page_slice(Entries, Page), + Cids = entry_cids(Slice), + actor_outbox_full_response_for(Id, F, Tip, Page, Cids) end end. +%% kernel_actor_log_data/2 — synchronous query to the kernel for +%% the actor's tip + flat entry list. nil when the kernel atom isn't +%% registered or the actor isn't present (mirrors kernel_log_tip/2's +%% guard pattern). + +kernel_actor_log_data(Kernel, Id) when is_atom(Kernel) -> + case erlang:whereis(Kernel) of + undefined -> nil; + _ -> + L = binary_to_list(Id), + A = list_to_atom(L), + T = nx_kernel:log_tip_for(A), + case T of + N when is_integer(N) -> + case nx_kernel:log_state_for(A) of + {ok, LogState} -> {N, log:entries(LogState)}; + _ -> {N, []} + end; + _ -> nil + end + end; +kernel_actor_log_data(_, _) -> nil. + +%% page_size/0 — small for v2 (proof of concept). Real outboxes +%% pick a larger page size (Mastodon defaults to 20). Tests pin +%% this to 5 so 3 publishes fit in one page and 6 publishes +%% straddle two pages. + +page_size() -> 5. + +%% parse_page/1 — accept `?page=N` from the query string. `nil` or +%% missing param -> page 1. Non-positive values clamp to 1. + +parse_page(nil) -> 1; +parse_page(Q) when is_binary(Q) -> + case match_prefix(<<112,97,103,101,61>>, Q) of % "page=" + {ok, Rest} -> + case parse_int(Rest) of + {ok, N} when N >= 1 -> N; + _ -> 1 + end; + _ -> 1 + end; +parse_page(_) -> 1. + +parse_int(Bin) -> + L = binary_to_list(Bin), + case L of + [] -> error; + _ -> + case all_digits(L) of + true -> {ok, list_to_integer(L)}; + false -> error + end + end. + +all_digits([]) -> true; +all_digits([C | Rest]) when C >= 48, C =< 57 -> all_digits(Rest); +all_digits(_) -> false. + +%% page_slice/2 — extract a page-sized slice of Entries. Page is +%% 1-indexed; out-of-range pages yield []. + +page_slice(Entries, Page) -> + Sz = page_size(), + Start = (Page - 1) * Sz, + drop_take(Entries, Start, Sz). + +drop_take(_, _, 0) -> []; +drop_take([], _, _) -> []; +drop_take(L, 0, N) -> take(L, N); +drop_take([_ | Rest], K, N) -> drop_take(Rest, K - 1, N). + +take(_, 0) -> []; +take([], _) -> []; +take([H | Rest], N) -> [H | take(Rest, N - 1)]. + +entry_cids([]) -> []; +entry_cids([E | Rest]) -> + case envelope:get_field(id, E) of + {ok, Cid} -> [Cid | entry_cids(Rest)]; + _ -> entry_cids(Rest) + end. + %% kernel_log_tip/2 — query the kernel for an actor's log tip via %% `nx_kernel:log_tip_for/1`. Returns the tip integer when the actor %% exists, `nil` when the kernel atom isn't registered or the actor @@ -739,6 +836,84 @@ actor_outbox_with_tip_response_for(Id, sx, Tip) -> actor_outbox_with_tip_response_for(Id, _, Tip) -> actor_outbox_with_tip_response_for(Id, text, Tip). +%% actor_outbox_full_response_for/5 — Step 4d body shape includes +%% the actor id, tip, current page number, and the page's CID list. +%% Empty Cids degrades to the /tip/ variant — keeps the 4c body +%% shape stable when an actor has no entries (e.g. a Bob with zero +%% publishes). + +actor_outbox_full_response_for(Id, F, Tip, _Page, []) -> + actor_outbox_with_tip_response_for(Id, F, Tip); +actor_outbox_full_response_for(Id, text, Tip, Page, Cids) -> + Pre = <<111,117,116,98,111,120,58,32>>, % "outbox: " + Tipp = <<10,116,105,112,58,32>>, % "\ntip: " + Pag = <<10,112,97,103,101,58,32>>, % "\npage: " + Itm = <<10,105,116,101,109,58,32>>, % "\nitem: " + TipBin = list_to_binary(integer_to_list(Tip)), + PageBin = list_to_binary(integer_to_list(Page)), + Head = <
>, + Body = lines_with_prefix(Head, Itm, Cids, <<10>>), + ok_response(Body); +actor_outbox_full_response_for(Id, json, Tip, Page, Cids) -> + Body = json_outbox_body(Id, Tip, Page, Cids), + ok_response(Body, json); +actor_outbox_full_response_for(Id, activity_json, Tip, Page, Cids) -> + Body = json_outbox_body(Id, Tip, Page, Cids), + ok_response(Body, activity_json); +actor_outbox_full_response_for(Id, sx, Tip, Page, Cids) -> + Body = sx_outbox_body(Id, Tip, Page, Cids), + ok_response(Body, sx); +actor_outbox_full_response_for(Id, _, Tip, Page, Cids) -> + actor_outbox_full_response_for(Id, text, Tip, Page, Cids). + +lines_with_prefix(Acc, _, [], Tail) -> <>; +lines_with_prefix(Acc, Itm, [C | Rest], Tail) -> + lines_with_prefix(< >, Itm, Rest, Tail). + +%% {"outbox":" ","tip":N,"page":P,"items":["cid1","cid2",...]} +json_outbox_body(Id, Tip, Page, Cids) -> + Pre = <<123,34,111,117,116,98,111,120,34,58,34>>, + Mid1 = <<34,44,34,116,105,112,34,58>>, % '","tip":' + Mid2 = <<44,34,112,97,103,101,34,58>>, % ',"page":' + Mid3 = <<44,34,105,116,101,109,115,34,58,91>>, % ',"items":[' + Suf = <<93,125,10>>, % ']}\n' + TipBin = list_to_binary(integer_to_list(Tip)), + PageBin = list_to_binary(integer_to_list(Page)), + Items = json_string_list(Cids), + < >. + +json_string_list([]) -> <<>>; +json_string_list([C]) -> <<34, C/binary, 34>>; +json_string_list([C | Rest]) -> + Tail = json_string_list(Rest), + <<34, C/binary, 34, 44, Tail/binary>>. + +%% (outbox "" :tip N :page P :items ("cid1" "cid2" ...)) +sx_outbox_body(Id, Tip, Page, Cids) -> + Pre = <<40,111,117,116,98,111,120,32,34>>, % '(outbox "' + Mid1 = <<34,32,58,116,105,112,32>>, % '" :tip ' + Mid2 = <<32,58,112,97,103,101,32>>, % ' :page ' + Mid3 = <<32,58,105,116,101,109,115,32,40>>, % ' :items (' + Suf = <<41,41,10>>, % '))\n' + TipBin = list_to_binary(integer_to_list(Tip)), + PageBin = list_to_binary(integer_to_list(Page)), + Items = sx_string_list(Cids), + < >. + +sx_string_list([]) -> <<>>; +sx_string_list([C]) -> <<34, C/binary, 34>>; +sx_string_list([C | Rest]) -> + Tail = sx_string_list(Rest), + <<34, C/binary, 34, 32, Tail/binary>>. + %% "inbox: " — 7 bytes actor_inbox_get_response_for(Id, text) -> Pre = <<105,110,98,111,120,58,32>>, diff --git a/next/kernel/nx_kernel.erl b/next/kernel/nx_kernel.erl index 8634b198..da98c6c4 100644 --- a/next/kernel/nx_kernel.erl +++ b/next/kernel/nx_kernel.erl @@ -17,7 +17,7 @@ %% gen_server API -export([start_link/3, publish/1, query/0, log_tip/0, with_projections/1, stop/0, - add_actor/3, publish_to/2, log_tip_for/1, + add_actor/3, publish_to/2, log_tip_for/1, log_state_for/1, actors/0, state_for/1, bucket_for/1, with_projections_for/2, bootstrap_actor/3]). @@ -321,6 +321,9 @@ publish_to(ActorId, Request) -> log_tip_for(ActorId) -> gen_server:call(nx_kernel, {log_tip_for, ActorId}). +log_state_for(ActorId) -> + gen_server:call(nx_kernel, {log_state_for, ActorId}). + actors() -> gen_server:call(nx_kernel, get_actors). @@ -366,6 +369,8 @@ handle_call({publish_to, ActorId, Request}, _From, State) -> end; handle_call({log_tip_for, ActorId}, _From, State) -> {reply, actor_log_tip(ActorId, State), State}; +handle_call({log_state_for, ActorId}, _From, State) -> + {reply, actor_log_state(ActorId, State), State}; handle_call(get_actors, _From, State) -> {reply, actors(State), State}; handle_call({state_for, ActorId}, _From, State) -> diff --git a/next/tests/http_multi_actor.sh b/next/tests/http_multi_actor.sh index 0ae87d99..e7a76d5c 100755 --- a/next/tests/http_multi_actor.sh +++ b/next/tests/http_multi_actor.sh @@ -180,6 +180,43 @@ cat > "$TMPFILE" <<'EPOCHS' (epoch 56) (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>>, 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}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,98,111,98,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,98,111,98,10,116,105,112,58,32,48>>, B) =/= nomatch\") :name)") +;; ── Step 4d: outbox listing from log entries + pagination ────── +;; Once entries exist, the outbox body includes a "page: N" line +;; and one "item:" line per CID on the page. Default page = 1, +;; page_size = 5. Empty actor still degrades to the 4c tip-only body. + +;; After 1 publish: text body has "outbox: alice\ntip: 1\npage: 1\nitem: \n" prefix +(epoch 60) +(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>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,49,10,112,97,103,101,58,32,49,10,105,116,101,109,58,32>>, B) =/= nomatch\") :name)") + +;; After 3 publishes: text body's tip=3 and contains item: substrings +(epoch 61) +(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>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,51,10,112,97,103,101,58,32,49,10,105,116,101,109,58,32>>, B) =/= nomatch\") :name)") + +;; Page 2 with only 3 publishes -> empty items list, degrades to tip-only body +(epoch 62) +(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>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {query, <<112,97,103,101,61,50>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<105,116,101,109,58>>, B) =:= nomatch andalso http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,51>>, B) =/= nomatch\") :name)") + +;; 6 publishes, page=1 -> body shows page: 1 and tip: 6 +(epoch 63) +(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>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,54,10,112,97,103,101,58,32,49,10,105,116,101,109,58,32>>, B) =/= nomatch\") :name)") + +;; 6 publishes, page=2 -> body shows page: 2 and item: prefix (1 item, but body byte_size > page-2-with-empty) +(epoch 64) +(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>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {query, <<112,97,103,101,61,50>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,54,10,112,97,103,101,58,32,50,10,105,116,101,109,58,32>>, B) =/= nomatch\") :name)") + +;; JSON outbox carries items array with 1 entry after 1 publish +(epoch 65) +(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>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<123,34,111,117,116,98,111,120,34,58,34,97,108,105,99,101,34,44,34,116,105,112,34,58,49,44,34,112,97,103,101,34,58,49,44,34,105,116,101,109,115,34,58,91,34>>, B) =/= nomatch\") :name)") + +;; SX outbox carries :items list with 1 entry +(epoch 66) +(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>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,115,120>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<40,111,117,116,98,111,120,32,34,97,108,105,99,101,34,32,58,116,105,112,32,49,32,58,112,97,103,101,32,49,32,58,105,116,101,109,115,32,40,34>>, B) =/= nomatch\") :name)") + +;; Bad ?page= still defaults to page 1 +(epoch 67) +(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>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {query, <<112,97,103,101,61,98,97,100>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,49,10,112,97,103,101,58,32,49>>, B) =/= nomatch\") :name)") + ;; route/2 path (no kernel arg) still returns the 4a stub — back-compat (epoch 57) (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), Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req, []), [_, _, {body, B}] = R, http_server:match_prefix(<<116,105,112,58>>, B) =:= nomatch\") :name)") @@ -243,6 +280,14 @@ check 54 "JSON outbox carries tip field" "true" check 55 "SX outbox carries :tip field" "true" check 56 "Bob outbox tip independent" "true" check 57 "route/2 unchanged (no tip)" "true" +check 60 "outbox tip=1 + page=1 + item:" "true" +check 61 "outbox tip=3 + page=1 + item:" "true" +check 62 "page=2 with 3 items -> empty page" "true" +check 63 "outbox tip=6 page=1 has item:" "true" +check 64 "outbox tip=6 page=2 has item:" "true" +check 65 "JSON body items array shape" "true" +check 66 "SX body :items list shape" "true" +check 67 "bad ?page= falls back to page 1" "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 6c8cefdd..07ff2313 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -300,10 +300,19 @@ token; the token now maps to an `:actor_id` rather than a fixed `alice`. delivery); the live handler does a bare `nx_kernel:log_tip_for/1` + integer guard instead. 8 new cases in `http_multi_actor.sh` (33/33 total). -- [ ] **4d** — Per-actor outbox listing reads from the named - bucket's log entries via `nx_kernel:actor_log_state/2`, content- - negotiates as today (text / json / sx). `?page=N` pagination - layered on top using `log:replay/3`. +- [x] **4d** — Per-actor outbox listing reads from the named + bucket's log entries via new `nx_kernel:log_state_for/1` + gen_server export. `actor_outbox_full_response_for/5` renders + text / JSON / SX bodies with `:tip`, `:page`, and the page's + `:items` CID list. Empty pages degrade to the 4c tip-only body + to preserve back-compat with epochs 50-57. `?page=N` pagination + parsed at `route/2` time and threaded via Cfg as + `{request_query, Q}`; `page_size/0` returns 5 (proof of concept + — production picks 20+). 8 new cases in `http_multi_actor.sh` + (41/41 total). Substrate gotcha: named recursive funs + `fun F(...) -> ... F(...) end` not supported; `binary:matches/2` + and `lists:foreach/2` not registered — tests prove behaviour + via `match_prefix` substring checks rather than counting. - [ ] **4e** — POST /actors/ /inbox stays a 202 stub for 4a-4d. Step 5 lands the real ingestion pipeline (sig verify + inbox- bucket append + projection broadcast). @@ -749,6 +758,23 @@ proceed. Newest first. +- **2026-06-06** — Step 4d: per-actor outbox listing + pagination. + New `nx_kernel:log_state_for/1` gen_server export returns + `{ok, LogState}` for an actor. `actor_outbox_response_for/3` + now extracts `{Tip, Entries}` via `kernel_actor_log_data/2`, + parses `?page=N` from the Req's `:query` field (threaded + through Cfg as `{request_query, Q}`), and renders a paged + body. Text body adds `page: N\nitem: \n...`; JSON adds + `"page":N,"items":[...]`; SX adds `:page N :items (...)`. + Empty pages (out-of-range or actor-with-no-publishes) degrade + back to the 4c tip-only shape, preserving epochs 50-57. + `page_size/0` is 5 for tests (production picks 20+). 8 new + cases in `http_multi_actor.sh` (41/41 total). Conformance + 761/761. 117/117 across 11 Step-4-adjacent suites. **Gotcha** + noted: named recursive funs `fun F(...) -> ... F(...) end` + fail with "fun-ref syntax not yet supported"; `binary:matches/2` + and `lists:foreach/2` aren't registered in this substrate. + - **2026-06-06** — Step 4c: route/3 with kernel access. `http_server:route/3(Req, Cfg, Kernel)` folds the kernel reference into Cfg as `{kernel, _}`. Dispatch chain refactored