-module(http_server). -export([start/1, start/2]). -export([route/1, route/2, route/3, ok_response/1, not_found_response/0, welcome_body/0, capabilities_body/0, capabilities_path/0, match_prefix/2, actors_prefix/0, actor_doc_response/1, artifacts_prefix/0, artifact_response/1, projections_list_path/0, projections_prefix/0, projections_list_response/0, projection_response/1, activity_path/0, unauthorized_response/0, post_activity_response/0, validation_failed_response/0, cid_response/1, accept_format/1, accept_format_from/1, capabilities_body_for/1, content_type_for/1, ok_response/2, cid_response_for/2, post_activity_response_for/1, actor_doc_response_for/2, artifact_response_for/2, projection_response_for/2, projections_list_response_for/1, actor_outbox_response_for/2, actor_outbox_response_for/3, actor_inbox_get_response_for/2, actor_followers_response_for/2, actor_following_response_for/2, actor_inbox_post_response/0, accepted_response/1, split_first_slash/1]). %% HTTP request router per design §16.1. %% %% Request shape (mirrors what the SX-side `http-listen` builds and %% the http:listen/2 BIF bridge marshals into a proplist): %% [{method, Binary}, {path, Binary}, {query, Binary}, %% {headers, [{Name, Value}, ...]}, {body, Binary}] %% %% Response shape: %% [{status, Integer}, {headers, [{Name, Value}, ...]}, {body, Binary}] %% %% Real dispatch (actor docs, outbox listings, /activity POST, %% /.well-known/sx-capabilities, etc.) lands in Step 8c+. Step 8b %% wires the route/1 shape and a single hello-world handler that %% proves the request→response round-trip. %% %% Method/path comparison uses integer-segment binaries because %% `<<"GET">>` truncates to a single byte in this port. %% Step 8b-start. `http:listen/2` blocks the calling process %% forever (it's a native accept-loop on a TCP socket), so callers %% wrap it in a spawned Erlang process. `start/1` is the bare form; %% `start/2` accepts the same Cfg proplist that `route/2` uses so %% the spawned handler closes over `:publish_token`, etc. %% %% Returns the Pid of the listener process; the caller can `link` %% it or `monitor` it as needed. The handler always returns a %% response — uncaught Erlang errors become a generic 500 via the %% native primitive's try/with-fallback in sx_server.ml. start(Port) -> start(Port, []). start(Port, Cfg) -> spawn(fun () -> http:listen(Port, fun (Req) -> route(Req, Cfg) end) end). route(Req) -> route(Req, []). %% route/2 — Cfg proplist carries optional `:publish_token` / %% `:tokens` (POST /activity auth) and optional `:kernel` %% (per-actor handlers — Step 4c). route/3 is sugar that puts %% Kernel into Cfg. 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); {<<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>>} -> ok_response(capabilities_body_for(F)); _ -> 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 %% reference (typically the registered `nx_kernel` atom). It's %% folded into Cfg under `:kernel` so handlers can look it up %% without a separate threading argument. route(Req, Cfg, Kernel) -> route(Req, [{kernel, Kernel} | Cfg]). %% Backward-compat /2 wrapper — defaults to text format. Route %% computes Format from the Accept header and calls dispatch/4 %% directly; dispatch/2 and dispatch/3 are kept for callers that %% don't have a format / Cfg in scope. dispatch(M, P) -> dispatch(M, P, text, []). dispatch(M, P, F) -> dispatch(M, P, F, []). %% 71 69 84 = "GET" | 47 = "/" dispatch(<<71, 69, 84>>, <<47>>, _F, _Cfg) -> ok_response(welcome_body()); %% GET /.well-known/sx-capabilities — Format threaded through 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 /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) -> projections_list_response_for(F); %% GET /actors/{id}[/sub] or /artifacts/{cid} or /projections/{name} dispatch(<<71, 69, 84>>, Path, F, Cfg) -> case match_prefix(actors_prefix(), Path) of {ok, Rest} when byte_size(Rest) > 0 -> actor_get(Rest, F, Cfg); _ -> case match_prefix(artifacts_prefix(), Path) of {ok, Cid} when byte_size(Cid) > 0 -> artifact_response_for(Cid, F); _ -> case match_prefix(projections_prefix(), Path) of {ok, Name} when byte_size(Name) > 0 -> projection_response_for(Name, F); _ -> not_found_response() end end end; %% POST /actors/{id}/inbox — peer-side delivery (Step 4a returns %% 202 Accepted stub; Step 5 lands the real ingestion pipeline). dispatch(<<80, 79, 83, 84>>, Path, _F, _Cfg) -> case match_prefix(actors_prefix(), Path) of {ok, Rest} when byte_size(Rest) > 0 -> actor_post(Rest); _ -> not_found_response() end; dispatch(_, _, _, _) -> not_found_response(). %% actor_get/3 — Rest is the part after "/actors/". If it has no %% inner slash, it's the bare actor doc. Otherwise dispatch on the %% sub-segment. Cfg flows through so sub-resource handlers can %% read `:kernel` for per-actor state lookup (Step 4c). actor_get(Rest, F, Cfg) -> case split_first_slash(Rest) of {Id, <<>>} -> actor_doc_response_for(Id, F); {Id, Sub} -> actor_subresource_get(Id, Sub, F, Cfg); Id -> actor_doc_response_for(Id, F) end. %% 111 117 116 98 111 120 = "outbox" actor_subresource_get(Id, <<111,117,116,98,111,120>>, F, Cfg) -> actor_outbox_response_for(Id, F, Cfg); %% 105 110 98 111 120 = "inbox" actor_subresource_get(Id, <<105,110,98,111,120>>, F, _Cfg) -> actor_inbox_get_response_for(Id, F); %% 102 111 108 108 111 119 101 114 115 = "followers" actor_subresource_get(Id, <<102,111,108,108,111,119,101,114,115>>, F, _Cfg) -> actor_followers_response_for(Id, F); %% 102 111 108 108 111 119 105 110 103 = "following" actor_subresource_get(Id, <<102,111,108,108,111,119,105,110,103>>, F, _Cfg) -> actor_following_response_for(Id, F); actor_subresource_get(_, _, _, _) -> not_found_response(). actor_post(Rest) -> case split_first_slash(Rest) of {_Id, <<105,110,98,111,120>>} -> actor_inbox_post_response(); _ -> not_found_response() end. %% split_first_slash/1 — split a binary on the first slash. Returns %% {Before, After} where After omits the slash itself. If no slash %% is present, returns just Before. 47 = "/". %% %% <<"alice">> -> <<"alice">> %% <<"alice/">> -> {<<"alice">>, <<>>} %% <<"alice/inbox">> -> {<<"alice">>, <<"inbox">>} %% <<"alice/inbox/x">> -> {<<"alice">>, <<"inbox/x">>} split_first_slash(Bin) -> split_first_slash(Bin, <<>>). split_first_slash(<<>>, Acc) -> Acc; split_first_slash(<<47, Rest/binary>>, Acc) -> {Acc, Rest}; split_first_slash(<>, Acc) -> split_first_slash(Rest, <>). %% "fed-sx kernel m1\n" — 17 bytes, hand-spelled. %% f e d - s x _ k e r n e l _ m 1 \n welcome_body() -> <<102,101,100,45,115,120,32,107,101,114,110,101,108,32,109,49,10>>. %% "/.well-known/sx-capabilities" — exposed for callers that build %% requests in tests or that need the canonical path string. capabilities_path() -> <<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>>. %% Capability descriptor body. Returned as plain text per design %% §16; future content-negotiation work (Step 8d) layers JSON / %% dag-cbor / SX representations on top. %% %% Lines (each terminated by \n = 10): %% "kernel: fed-sx-m1\n" %% "version: 0.0.1\n" %% "verbs: Create Update Delete\n" capabilities_body() -> <<107,101,114,110,101,108,58,32,102,101,100,45,115,120,45,109,49,10, 118,101,114,115,105,111,110,58,32,48,46,48,46,49,10, 118,101,114,98,115,58,32,67,114,101,97,116,101,32,85,112,100,97,116,101,32,68,101,108,101,116,101,10>>. ok_response(Body) -> [{status, 200}, {headers, []}, {body, Body}]. not_found_response() -> [{status, 404}, {headers, []}, {body, <<110,111,116,32,102,111,117,110,100,10>>}]. % "not found\n" %% Internal property-list field lookup. Returns nil when missing %% so the route falls into the not_found arm gracefully. field(K, [{K, V} | _]) -> V; field(K, [_ | Rest]) -> field(K, Rest); field(_, []) -> nil. %% ── Dynamic-segment routing ───────────────────────────────────── %% %% match_prefix(Prefix, Path) — if Path starts with the entire %% Prefix binary, return {ok, Rest} where Rest is the remaining %% bytes; else return nomatch. Pure byte-level pattern match, %% no regex / no parsing. Path-segment splitting comes in later %% sub-deliverables (8c-art, 8c-proj) where it's needed. match_prefix(<<>>, Rest) -> {ok, Rest}; match_prefix(<>, <>) -> match_prefix(PRest, PathRest); match_prefix(_, _) -> nomatch. %% "/actors/" — 8 bytes: 47 97 99 116 111 114 115 47 actors_prefix() -> <<47,97,99,116,111,114,115,47>>. %% Actor doc stub. Real implementation (Step 8c continuation) will %% fetch the actor-state projection entry and serialise it; v1 %% returns the id as the body so route resolution can be exercised %% end-to-end without the projection wiring. actor_doc_response(Id) -> %% "actor: " — 7 bytes Pre = <<97,99,116,111,114,58,32>>, Body = <
>,
    ok_response(Body).

%% "/artifacts/" — 11 bytes
artifacts_prefix() ->
    <<47,97,114,116,105,102,97,99,116,115,47>>.

%% Artifact stub. Real implementation will fetch the bytes from
%% the registry (or a CID-keyed store) and content-negotiate.
%% v1 echoes the CID so route resolution can be tested.
artifact_response(Cid) ->
    %% "artifact: " — 10 bytes
    Pre = <<97,114,116,105,102,97,99,116,58,32>>,
    Body = <
>,
    ok_response(Body).

%% "/projections" — 12 bytes (no trailing slash; the list endpoint)
projections_list_path() ->
    <<47,112,114,111,106,101,99,116,105,111,110,115>>.

%% "/projections/" — 13 bytes (the per-projection prefix)
projections_prefix() ->
    <<47,112,114,111,106,101,99,116,105,111,110,115,47>>.

%% Stub list response — real implementation queries the registry
%% for active projections and serialises the name+CID list.
projections_list_response() ->
    %% "projections: (empty)\n" — hand-spelled
    Body = <<112,114,111,106,101,99,116,105,111,110,115,58,32,
             40,101,109,112,116,121,41,10>>,
    ok_response(Body).

projection_response(Name) ->
    %% "projection: " — 12 bytes
    Pre = <<112,114,111,106,101,99,116,105,111,110,58,32>>,
    Body = <
>,
    ok_response(Body).

%% "/activity" — 9 bytes
activity_path() ->
    <<47,97,99,116,105,118,105,116,121>>.

%% 401 Unauthorized response. Body: "unauthorized\n" = 13 bytes.
unauthorized_response() ->
    [{status, 401}, {headers, []},
     {body, <<117,110,97,117,116,104,111,114,105,122,101,100,10>>}].

%% Stub success body for POST /activity. Real impl will return
%% the published activity's CID once outbox:publish is wired
%% through a server-state context (Step 8c-post-publish).
post_activity_response() ->
    %% "published (stub)\n" — hand-spelled
    Body = <<112,117,98,108,105,115,104,101,100,32,
             40,115,116,117,98,41,10>>,
    ok_response(Body).

%% Auth helpers.

handle_post_activity(Req, Cfg) ->
    case check_bearer(Req, Cfg) of
        {ok, ActorRef} ->
            F = accept_format_from(Req),
            publish_if_kernel(Req, F, ActorRef);
        {error, _} ->
            unauthorized_response()
    end.

%% 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.
%%
%% 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}],
            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;
                {error, _} ->
                    validation_failed_response()
            end
    end.

%% 200 OK with body "cid: \n" (5 prefix bytes + cid + newline)
cid_response(Cid) ->
    %% "cid: " — 99 105 100 58 32
    Pre = <<99,105,100,58,32>>,
    Body = <
>,
    ok_response(Body).

%% 422 Unprocessable Entity. Body "validation failed\n" — 18 bytes.
validation_failed_response() ->
    [{status, 422}, {headers, []},
     {body, <<118,97,108,105,100,97,116,105,111,110,32,
              102,97,105,108,101,100,10>>}].

check_bearer(Req, Cfg) ->
    case bearer_token(Req) of
        {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
        nil -> not_found;
        Hs ->
            %% "authorization" — 13 bytes, lowercase as the BIF wrapper
            %% normalises headers to lowercase keys.
            AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>,
            case find_header(AuthKey, Hs) of
                not_found -> not_found;
                {ok, V} -> strip_bearer(V)
            end
    end.

find_header(_, []) -> not_found;
find_header(K, [{K, V} | _]) -> {ok, V};
find_header(K, [_ | Rest]) -> find_header(K, Rest).

%% "Bearer " — 7 bytes — strip and return the rest as the token.
%% Anything else returns not_found (treated as missing auth).
strip_bearer(V) ->
    Prefix = <<66,101,97,114,101,114,32>>,
    case match_prefix(Prefix, V) of
        {ok, Token} when byte_size(Token) > 0 -> {ok, Token};
        _ -> not_found
    end.


%% ── Step 8d: Accept-header parsing ──────────────────────────────
%%
%% accept_format/1 — given an Accept header value, return the
%% content-negotiation atom the route should serialise into. The
%% first media-type prefix that matches wins, in this priority:
%%   application/activity+json -> activity_json
%%   application/json          -> json
%%   application/sx            -> sx
%%   application/cbor          -> cbor
%% Anything else (including unrecognised, empty, or missing header)
%% returns text — current routes default to text/plain bodies.
%%
%% Per-prefix recognition uses `match_prefix`. The header value is
%% NOT split on `,` here; matching against the leading bytes is
%% enough for the v1 envelope shapes the kernel currently emits.

%% Media-type prefix byte sequences — hand-spelled because
%% `<<"...">>` string-segments truncate in this port.

%% "application/activity+json" — 25 bytes
activity_json_prefix() ->
    <<97,112,112,108,105,99,97,116,105,111,110,47,
      97,99,116,105,118,105,116,121,43,106,115,111,110>>.

%% "application/json" — 16 bytes
json_prefix() ->
    <<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>.

%% "application/sx" — 14 bytes
sx_prefix() ->
    <<97,112,112,108,105,99,97,116,105,111,110,47,115,120>>.

%% "application/cbor" — 16 bytes
cbor_prefix() ->
    <<97,112,112,108,105,99,97,116,105,111,110,47,99,98,111,114>>.

accept_format(nil) -> text;
accept_format(<<>>) -> text;
accept_format(V) when is_binary(V) ->
    case match_prefix(activity_json_prefix(), V) of
        {ok, _} -> activity_json;
        _ ->
            case match_prefix(json_prefix(), V) of
                {ok, _} -> json;
                _ ->
                    case match_prefix(sx_prefix(), V) of
                        {ok, _} -> sx;
                        _ ->
                            case match_prefix(cbor_prefix(), V) of
                                {ok, _} -> cbor;
                                _ -> text
                            end
                    end
            end
    end;
accept_format(_) -> text.

%% accept_format_from/1 — pull the Accept header out of a request
%% proplist and run accept_format on its value. Lowercase key name
%% (matches the BIF wrapper's normalisation).
accept_format_from(Req) ->
    case field(headers, Req) of
        nil -> text;
        Hs ->
            %% "accept" — 6 bytes
            K = <<97,99,99,101,112,116>>,
            case find_header(K, Hs) of
                {ok, V} -> accept_format(V);
                not_found -> text
            end
    end.

%% capabilities_body_for/1 — content-negotiated capability bodies.
%% Each format returns a distinct byte sequence so dispatch can be
%% observed end-to-end. Real serialisation (JSON-LD, dag-cbor, etc.)
%% lands once the corresponding encoder BIFs are wired; v1 uses
%% tagged stubs that are syntactically the right shape.
capabilities_body_for(text) ->
    capabilities_body();
%% `{"caps":"fed-sx-m1"}\n` — 21 bytes
capabilities_body_for(json) ->
    <<123,34,99,97,112,115,34,58,34,
      102,101,100,45,115,120,45,109,49,34,125,10>>;
capabilities_body_for(activity_json) ->
    %% Same payload as :json — the difference is the Content-Type
    %% header (Step 8d-content-type follow-up); body shape matches.
    capabilities_body_for(json);
%% `(caps "fed-sx-m1")\n` — 19 bytes
capabilities_body_for(sx) ->
    <<40,99,97,112,115,32,34,
      102,101,100,45,115,120,45,109,49,34,41,10>>;
%% A minimal CBOR map: 0xA1 0x64 "caps" 0x69 "fed-sx-m1"
%% A1 = map(1); 64 = text(4) "caps"; 69 = text(9) "fed-sx-m1"
capabilities_body_for(cbor) ->
    <<161,100,99,97,112,115,105,
      102,101,100,45,115,120,45,109,49>>;
capabilities_body_for(_) ->
    capabilities_body().

%% content_type_for/1 — MIME type binary for each format atom.
%% "text/plain"                 — 10 bytes
content_type_for(text) ->
    <<116,101,120,116,47,112,108,97,105,110>>;
%% "application/json"           — 16 bytes
content_type_for(json) ->
    <<97,112,112,108,105,99,97,116,105,111,110,47,
      106,115,111,110>>;
%% "application/activity+json"  — 25 bytes
content_type_for(activity_json) ->
    <<97,112,112,108,105,99,97,116,105,111,110,47,
      97,99,116,105,118,105,116,121,43,106,115,111,110>>;
%% "application/sx"             — 14 bytes
content_type_for(sx) ->
    <<97,112,112,108,105,99,97,116,105,111,110,47,
      115,120>>;
%% "application/cbor"           — 16 bytes
content_type_for(cbor) ->
    <<97,112,112,108,105,99,97,116,105,111,110,47,
      99,98,111,114>>;
content_type_for(_) ->
    content_type_for(text).

%% ok_response/2 — 200 OK with a Content-Type header derived from
%% the Format atom. The header key is lowercase to match how the
%% BIF wrapper normalises request headers.
%% "content-type" — 12 bytes
ok_response(Body, Format) ->
    CTKey = <<99,111,110,116,101,110,116,45,116,121,112,101>>,
    [{status, 200},
     {headers, [{CTKey, content_type_for(Format)}]},
     {body, Body}].

%% cid_response_for/2 — format-aware version of cid_response/1.
%% Each variant emits a syntactically appropriate body for the
%% chosen format and tags the response with the matching
%% Content-Type via ok_response/2.

cid_response_for(Cid, text) ->
    cid_response(Cid);
%% `{"cid":""}\n` — 8-byte prefix + cid + 3-byte suffix
cid_response_for(Cid, json) ->
    Pre = <<123,34,99,105,100,34,58,34>>,  % '{"cid":"'
    Suf = <<34,125,10>>,                    % '"}\n'
    ok_response(<
>, json);
cid_response_for(Cid, activity_json) ->
    Pre = <<123,34,99,105,100,34,58,34>>,
    Suf = <<34,125,10>>,
    ok_response(<
>, activity_json);
%% `(cid "")\n` — 6-byte prefix + cid + 3-byte suffix
cid_response_for(Cid, sx) ->
    Pre = <<40,99,105,100,32,34>>,          % '(cid "'
    Suf = <<34,41,10>>,                      % '")\n'
    ok_response(<
>, sx);
%% v1 cbor stub: the raw CID bytes with the application/cbor CT.
%% Real cbor encoding (A1 63 cid 78  ...) lands later.
cid_response_for(Cid, cbor) ->
    ok_response(Cid, cbor);
cid_response_for(Cid, _) ->
    cid_response(Cid).

%% post_activity_response_for/1 — format-aware version of
%% post_activity_response/0 (the kernel-absent stub).

post_activity_response_for(text) ->
    post_activity_response();
%% `{"status":"stub"}\n` — hand-spelled
post_activity_response_for(json) ->
    Body = <<123,34,115,116,97,116,117,115,34,58,34,
             115,116,117,98,34,125,10>>,
    ok_response(Body, json);
post_activity_response_for(activity_json) ->
    Body = <<123,34,115,116,97,116,117,115,34,58,34,
             115,116,117,98,34,125,10>>,
    ok_response(Body, activity_json);
%% `(status "stub")\n`
post_activity_response_for(sx) ->
    Body = <<40,115,116,97,116,117,115,32,34,
             115,116,117,98,34,41,10>>,
    ok_response(Body, sx);
post_activity_response_for(cbor) ->
    %% Same body as text but with cbor CT — clients see the same
    %% bytes as the text fallback. Step 8d-cbor encoder will replace.
    [_, _, {body, Body}] = post_activity_response(),
    ok_response(Body, cbor);
post_activity_response_for(_) ->
    post_activity_response().

%% ── 8d-dispatch-get: format-aware GET responses ─────────────────
%%
%% Each builder mirrors its text-only counterpart but emits a
%% format-tagged body and Content-Type. json/activity_json share
%% the body shape but differ in CT; sx uses parenthesized form;
%% cbor returns the raw payload bytes (encoder follow-up).

%% actor_doc_response — text body `actor: \n`.

actor_doc_response_for(Id, text) ->
    actor_doc_response(Id);
actor_doc_response_for(Id, json) ->
    Pre = <<123,34,97,99,116,111,114,34,58,34>>,  % '{"actor":"'
    Suf = <<34,125,10>>,                           % '"}\n'
    ok_response(<
>, json);
actor_doc_response_for(Id, activity_json) ->
    Pre = <<123,34,97,99,116,111,114,34,58,34>>,
    Suf = <<34,125,10>>,
    ok_response(<
>, activity_json);
actor_doc_response_for(Id, sx) ->
    Pre = <<40,97,99,116,111,114,32,34>>,          % '(actor "'
    Suf = <<34,41,10>>,                             % '")\n'
    ok_response(<
>, sx);
actor_doc_response_for(Id, cbor) ->
    ok_response(Id, cbor);
actor_doc_response_for(Id, _) ->
    actor_doc_response(Id).

%% ── 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
%% can be tested end-to-end; real serialisation of per-actor outbox
%% listings (Step 4d) + follower-graph projection bodies (Step 6+)
%% layer on top of these dispatch arms.

%% "outbox: " — 8 bytes
actor_outbox_response_for(Id, text) ->
    Pre = <<111,117,116,98,111,120,58,32>>,
    ok_response(<
>);
actor_outbox_response_for(Id, json) ->
    Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,  % '{"outbox":"'
    Suf = <<34,125,10>>,
    ok_response(<
>, json);
actor_outbox_response_for(Id, activity_json) ->
    Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,
    Suf = <<34,125,10>>,
    ok_response(<
>, activity_json);
actor_outbox_response_for(Id, sx) ->
    Pre = <<40,111,117,116,98,111,120,32,34>>,         % '(outbox "'
    Suf = <<34,41,10>>,
    ok_response(<
>, sx);
actor_outbox_response_for(Id, _) ->
    Pre = <<111,117,116,98,111,120,58,32>>,
    ok_response(<
>).

%% actor_outbox_response_for/3 — Step 4c kernel-aware variant. When
%% Cfg carries a `:kernel` reference *and* the kernel has the actor,
%% include "tip: \n" after the bare body so callers can verify
%% the route landed on the right bucket. Falls back to the /2 stub
%% otherwise — same shape, same content-negotiation arms.

actor_outbox_response_for(Id, F, Cfg) ->
    case field(kernel, Cfg) of
        nil ->
            actor_outbox_response_for(Id, F);
        Kernel ->
            case kernel_actor_log_data(Kernel, Id) of
                nil ->
                    actor_outbox_response_for(Id, F);
                {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
%% isn't present. Catches everything so a stale Cfg can't break the
%% handler.

kernel_log_tip(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) -> N;
                _                    -> nil
            end
    end;
kernel_log_tip(_, _) -> nil.

actor_outbox_with_tip_response_for(Id, text, Tip) ->
    %% "outbox: \ntip: \n"
    Pre  = <<111,117,116,98,111,120,58,32>>,           % "outbox: "
    Tipp = <<10,116,105,112,58,32>>,                   % "\ntip: "
    TipBin = list_to_binary(integer_to_list(Tip)),
    Body = <
>,
    ok_response(Body);
actor_outbox_with_tip_response_for(Id, json, Tip) ->
    Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,
    Mid = <<34,44,34,116,105,112,34,58>>,              % '","tip":'
    Suf = <<125,10>>,                                  % '}\n'
    TipBin = list_to_binary(integer_to_list(Tip)),
    Body = <
>,
    ok_response(Body, json);
actor_outbox_with_tip_response_for(Id, activity_json, Tip) ->
    Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,
    Mid = <<34,44,34,116,105,112,34,58>>,
    Suf = <<125,10>>,
    TipBin = list_to_binary(integer_to_list(Tip)),
    Body = <
>,
    ok_response(Body, activity_json);
actor_outbox_with_tip_response_for(Id, sx, Tip) ->
    Pre = <<40,111,117,116,98,111,120,32,34>>,         % '(outbox "'
    Mid = <<34,32,58,116,105,112,32>>,                 % '" :tip '
    Suf = <<41,10>>,                                   % ')\n'
    TipBin = list_to_binary(integer_to_list(Tip)),
    Body = <
>,
    ok_response(Body, sx);
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>>,
    ok_response(<
>);
actor_inbox_get_response_for(Id, json) ->
    Pre = <<123,34,105,110,98,111,120,34,58,34>>,      % '{"inbox":"'
    Suf = <<34,125,10>>,
    ok_response(<
>, json);
actor_inbox_get_response_for(Id, activity_json) ->
    Pre = <<123,34,105,110,98,111,120,34,58,34>>,
    Suf = <<34,125,10>>,
    ok_response(<
>, activity_json);
actor_inbox_get_response_for(Id, sx) ->
    Pre = <<40,105,110,98,111,120,32,34>>,             % '(inbox "'
    Suf = <<34,41,10>>,
    ok_response(<
>, sx);
actor_inbox_get_response_for(Id, _) ->
    Pre = <<105,110,98,111,120,58,32>>,
    ok_response(<
>).

%% "followers: " — 11 bytes
actor_followers_response_for(Id, text) ->
    Pre = <<102,111,108,108,111,119,101,114,115,58,32>>,
    ok_response(<
>);
actor_followers_response_for(Id, json) ->
    Pre = <<123,34,102,111,108,108,111,119,101,114,115,34,58,34>>,
    Suf = <<34,125,10>>,
    ok_response(<
>, json);
actor_followers_response_for(Id, activity_json) ->
    Pre = <<123,34,102,111,108,108,111,119,101,114,115,34,58,34>>,
    Suf = <<34,125,10>>,
    ok_response(<
>, activity_json);
actor_followers_response_for(Id, sx) ->
    Pre = <<40,102,111,108,108,111,119,101,114,115,32,34>>,
    Suf = <<34,41,10>>,
    ok_response(<
>, sx);
actor_followers_response_for(Id, _) ->
    Pre = <<102,111,108,108,111,119,101,114,115,58,32>>,
    ok_response(<
>).

%% "following: " — 11 bytes
actor_following_response_for(Id, text) ->
    Pre = <<102,111,108,108,111,119,105,110,103,58,32>>,
    ok_response(<
>);
actor_following_response_for(Id, json) ->
    Pre = <<123,34,102,111,108,108,111,119,105,110,103,34,58,34>>,
    Suf = <<34,125,10>>,
    ok_response(<
>, json);
actor_following_response_for(Id, activity_json) ->
    Pre = <<123,34,102,111,108,108,111,119,105,110,103,34,58,34>>,
    Suf = <<34,125,10>>,
    ok_response(<
>, activity_json);
actor_following_response_for(Id, sx) ->
    Pre = <<40,102,111,108,108,111,119,105,110,103,32,34>>,
    Suf = <<34,41,10>>,
    ok_response(<
>, sx);
actor_following_response_for(Id, _) ->
    Pre = <<102,111,108,108,111,119,105,110,103,58,32>>,
    ok_response(<
>).

%% POST /actors//inbox stub — 202 Accepted with body "accepted\n".
%% Real ingestion pipeline (sig verify + envelope:get_field + log
%% append on the receiving actor's inbox bucket) lands in Step 5.

actor_inbox_post_response() ->
    %% "accepted\n" — 9 bytes
    Body = <<97,99,99,101,112,116,101,100,10>>,
    accepted_response(Body).

accepted_response(Body) ->
    [{status, 202}, {headers, []}, {body, Body}].

%% artifact_response — text body `artifact: \n`.

artifact_response_for(Cid, text) ->
    artifact_response(Cid);
artifact_response_for(Cid, json) ->
    Pre = <<123,34,97,114,116,105,102,97,99,116,34,58,34>>,
    Suf = <<34,125,10>>,
    ok_response(<
>, json);
artifact_response_for(Cid, activity_json) ->
    Pre = <<123,34,97,114,116,105,102,97,99,116,34,58,34>>,
    Suf = <<34,125,10>>,
    ok_response(<
>, activity_json);
artifact_response_for(Cid, sx) ->
    Pre = <<40,97,114,116,105,102,97,99,116,32,34>>,
    Suf = <<34,41,10>>,
    ok_response(<
>, sx);
artifact_response_for(Cid, cbor) ->
    ok_response(Cid, cbor);
artifact_response_for(Cid, _) ->
    artifact_response(Cid).

%% projection_response (singular) — text body `projection: \n`.

projection_response_for(Name, text) ->
    projection_response(Name);
projection_response_for(Name, json) ->
    Pre = <<123,34,112,114,111,106,101,99,116,105,111,110,34,58,34>>,
    Suf = <<34,125,10>>,
    ok_response(<
>, json);
projection_response_for(Name, activity_json) ->
    Pre = <<123,34,112,114,111,106,101,99,116,105,111,110,34,58,34>>,
    Suf = <<34,125,10>>,
    ok_response(<
>, activity_json);
projection_response_for(Name, sx) ->
    Pre = <<40,112,114,111,106,101,99,116,105,111,110,32,34>>,
    Suf = <<34,41,10>>,
    ok_response(<
>, sx);
projection_response_for(Name, cbor) ->
    ok_response(Name, cbor);
projection_response_for(Name, _) ->
    projection_response(Name).

%% projections_list_response — empty-list stub.

projections_list_response_for(text) ->
    projections_list_response();
%% `{"projections":[]}\n`
projections_list_response_for(json) ->
    Body = <<123,34,112,114,111,106,101,99,116,105,111,110,115,
             34,58,91,93,125,10>>,
    ok_response(Body, json);
projections_list_response_for(activity_json) ->
    Body = <<123,34,112,114,111,106,101,99,116,105,111,110,115,
             34,58,91,93,125,10>>,
    ok_response(Body, activity_json);
%% `(projections)\n`
projections_list_response_for(sx) ->
    Body = <<40,112,114,111,106,101,99,116,105,111,110,115,41,10>>,
    ok_response(Body, sx);
projections_list_response_for(cbor) ->
    [_, _, {body, Body}] = projections_list_response(),
    ok_response(Body, cbor);
projections_list_response_for(_) ->
    projections_list_response().