diff --git a/next/kernel/http_server.erl b/next/kernel/http_server.erl index fc2a8133..781de4a4 100644 --- a/next/kernel/http_server.erl +++ b/next/kernel/http_server.erl @@ -1,11 +1,13 @@ -module(http_server). --export([route/1, ok_response/1, not_found_response/0, +-export([route/1, route/2, 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]). + projections_list_response/0, projection_response/1, + activity_path/0, unauthorized_response/0, + post_activity_response/0]). %% HTTP request router per design §16.1. %% @@ -26,9 +28,21 @@ %% `<<"GET">>` truncates to a single byte in this port. route(Req) -> + route(Req, []). + +%% route/2 — Cfg proplist carries optional `:publish_token` (binary) +%% for POST /activity auth. Other state (logs, projections, etc.) is +%% not yet threaded through — POST /activity returns a stub 200 +%% once auth succeeds; real outbox:publish glue lands separately. +route(Req, Cfg) -> M = field(method, Req), P = field(path, Req), - dispatch(M, P). + case {M, P} of + {<<80,79,83,84>>, <<47,97,99,116,105,118,105,116,121>>} -> + handle_post_activity(Req, Cfg); + _ -> + dispatch(M, P) + end. %% 71 69 84 = "GET" | 47 = "/" dispatch(<<71, 69, 84>>, <<47>>) -> @@ -161,3 +175,74 @@ projection_response(Name) -> 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 ->
+            post_activity_response();
+        {error, _} ->
+            unauthorized_response()
+    end.
+
+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;
+        not_found -> {error, no_auth}
+    end.
+
+%% 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.
+
+expected_token(Cfg) ->
+    case field(publish_token, Cfg) of
+        nil -> not_found;
+        T -> {ok, T}
+    end.
diff --git a/next/tests/http_post_activity.sh b/next/tests/http_post_activity.sh
new file mode 100755
index 00000000..edea436f
--- /dev/null
+++ b/next/tests/http_post_activity.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+# next/tests/http_post_activity.sh — Step 8c-post-auth acceptance test.
+#
+# Exercises route/2 with bearer-token auth on POST /activity.
+# Cfg :publish_token is the expected token; mismatched / missing /
+# malformed Authorization header all 401. Real outbox:publish
+# wiring lands in a follow-up sub-deliverable. 12 cases.
+
+set -uo pipefail
+cd "$(git rev-parse --show-toplevel)"
+
+SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
+if [ ! -x "$SX_SERVER" ]; then
+  SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
+fi
+if [ ! -x "$SX_SERVER" ]; then
+  echo "ERROR: sx_server.exe not found." >&2
+  exit 1
+fi
+
+VERBOSE="${1:-}"
+PASS=0; FAIL=0; ERRORS=""
+TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT
+
+# Convenience: the bearer header name = "authorization"; "Bearer "
+# prefix = 7 bytes; a sample token = "foo".
+# Compose the right shapes inline in each test.
+
+cat > "$TMPFILE" <<'EPOCHS'
+(epoch 1)
+(load "lib/erlang/tokenizer.sx")
+(load "lib/erlang/parser.sx")
+(load "lib/erlang/parser-core.sx")
+(load "lib/erlang/parser-expr.sx")
+(load "lib/erlang/parser-module.sx")
+(load "lib/erlang/transpile.sx")
+(load "lib/erlang/runtime.sx")
+(load "lib/erlang/vm/dispatcher.sx")
+
+(epoch 2)
+(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
+
+;; activity_path is 9 bytes
+(epoch 10)
+(eval "(erlang-eval-ast \"byte_size(http_server:activity_path())\")")
+
+;; Authorized POST -> 200
+(epoch 11)
+(eval "(get (erlang-eval-ast \"Token = <<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>>, Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}]}, {body, <<>>}], Cfg = [{publish_token, Token}], case http_server:route(Req, Cfg) of [{status, 200} | _] -> ok; _ -> bad end\") :name)")
+
+;; Authorized body has 'published' prefix
+(epoch 12)
+(eval "(get (erlang-eval-ast \"Token = <<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>>, Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}]}, {body, <<>>}], Cfg = [{publish_token, Token}], R = http_server:route(Req, Cfg), case R of [_, _, {body, B}] -> http_server:match_prefix(<<112,117,98,108,105,115,104,101,100>>, B) =/= nomatch; _ -> false end\") :name)")
+
+;; No Authorization header -> 401
+(epoch 13)
+(eval "(get (erlang-eval-ast \"Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, []}, {body, <<>>}], Cfg = [{publish_token, <<102,111,111>>}], case http_server:route(Req, Cfg) of [{status, 401} | _] -> ok; _ -> bad end\") :name)")
+
+;; Wrong bearer token -> 401
+(epoch 14)
+(eval "(get (erlang-eval-ast \"AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AuthVal = <<66,101,97,114,101,114,32,98,97,100>>, Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}]}, {body, <<>>}], Cfg = [{publish_token, <<102,111,111>>}], case http_server:route(Req, Cfg) of [{status, 401} | _] -> ok; _ -> bad end\") :name)")
+
+;; Malformed Authorization (missing 'Bearer ') -> 401
+(epoch 15)
+(eval "(get (erlang-eval-ast \"AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AuthVal = <<102,111,111>>, Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}]}, {body, <<>>}], Cfg = [{publish_token, <<102,111,111>>}], case http_server:route(Req, Cfg) of [{status, 401} | _] -> ok; _ -> bad end\") :name)")
+
+;; Cfg without :publish_token -> 401 even with a bearer token present
+(epoch 16)
+(eval "(get (erlang-eval-ast \"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>>, Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}]}, {body, <<>>}], case http_server:route(Req, []) of [{status, 401} | _] -> ok; _ -> bad end\") :name)")
+
+;; route/1 (no Cfg) treats POST /activity as 401 (no token configured)
+(epoch 17)
+(eval "(get (erlang-eval-ast \"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>>, Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}]}, {body, <<>>}], case http_server:route(Req) of [{status, 401} | _] -> ok; _ -> bad end\") :name)")
+
+;; GET /activity -> 404 (only POST is /activity)
+(epoch 18)
+(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, http_server:activity_path()}], case http_server:route(Req) of [{status, 404} | _] -> ok; _ -> bad end\") :name)")
+
+;; Other authorized routes still work via route/2
+(epoch 19)
+(eval "(get (erlang-eval-ast \"Cfg = [{publish_token, <<102,111,111>>}], Req = [{method, <<71,69,84>>}, {path, <<47>>}], case http_server:route(Req, Cfg) of [{status, 200} | _] -> ok; _ -> bad end\") :name)")
+
+;; unauthorized_response shape sanity
+(epoch 20)
+(eval "(erlang-eval-ast \"R = http_server:unauthorized_response(), case R of [{status, 401} | _] -> 401; _ -> nope end\")")
+
+;; Empty bearer token (just \"Bearer \") -> 401
+(epoch 21)
+(eval "(get (erlang-eval-ast \"AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AuthVal = <<66,101,97,114,101,114,32>>, Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}]}, {body, <<>>}], Cfg = [{publish_token, <<102,111,111>>}], case http_server:route(Req, Cfg) of [{status, 401} | _] -> ok; _ -> bad end\") :name)")
+EPOCHS
+
+OUTPUT=$(timeout 120 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
+
+check() {
+  local epoch="$1" desc="$2" expected="$3"
+  local actual
+  actual=$(echo "$OUTPUT" | awk -v e="$epoch" '
+    $0 ~ "^\\(ok-len " e " " { getline; print; exit }
+    $0 ~ "^\\(ok " e " "     { print; exit }
+    $0 ~ "^\\(error " e " "  { print; exit }
+  ')
+  [ -z "$actual" ] && actual=""
+  if echo "$actual" | grep -qF -- "$expected"; then
+    PASS=$((PASS+1))
+    [ "$VERBOSE" = "-v" ] && echo "  ok $desc"
+  else
+    FAIL=$((FAIL+1))
+    ERRORS+="  FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual
+"
+  fi
+}
+
+check  2  "module load name"                  "http_server"
+check 10  "activity_path = 9 bytes"           "9"
+check 11  "authorized POST -> 200"            "ok"
+check 12  "body has 'published' prefix"       "true"
+check 13  "no Authorization -> 401"           "ok"
+check 14  "wrong token -> 401"                "ok"
+check 15  "malformed Authorization -> 401"    "ok"
+check 16  "Cfg without token -> 401"          "ok"
+check 17  "route/1 rejects POST /activity"    "ok"
+check 18  "GET /activity -> 404"              "ok"
+check 19  "other GETs work via route/2"       "ok"
+check 20  "unauthorized_response status 401"  "401"
+check 21  "empty bearer token -> 401"         "ok"
+
+TOTAL=$((PASS+FAIL))
+if [ $FAIL -eq 0 ]; then
+  echo "ok $PASS/$TOTAL next/tests/http_post_activity.sh passed"
+else
+  echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
+  echo "$ERRORS"
+fi
+[ $FAIL -eq 0 ]
diff --git a/plans/fed-sx-milestone-1.md b/plans/fed-sx-milestone-1.md
index 025315e4..d98c12e0 100644
--- a/plans/fed-sx-milestone-1.md
+++ b/plans/fed-sx-milestone-1.md
@@ -513,7 +513,8 @@ publish(ActorId, ActivityRequest) ->
 - [x] **8c-actors-doc** — `match_prefix/2` byte-level path-prefix matcher + GET `/actors/{id}` route returning an `actor: ` stub body. `/actors/{id}/outbox` deferred (needs path-segment splitting). `next/tests/http_actors.sh` (13 cases).
 - [x] **8c-art** — Route GET `/artifacts/{cid}` via `match_prefix`. Stub body echoes the cid (`artifact: \n`); real content store lookup deferred. `next/tests/http_artifacts.sh` (9 cases).
 - [x] **8c-proj** — Routes GET `/projections` (list stub) + GET `/projections/{name}` (state stub) via `match_prefix`. Bare-path list endpoint dispatches before the prefix clause. `next/tests/http_projections.sh` (11 cases). Registry-backed implementation deferred.
-- [ ] **8c-post** — POST `/activity` glue: parse body → call `outbox:publish` with bearer-token auth (env var `NEXT_PUBLISH_TOKEN`).
+- [x] **8c-post-auth** — `route/2(Req, Cfg)` adds POST `/activity` with bearer-token check. Cfg `:publish_token` is the expected token; missing / wrong / malformed Authorization all return 401. Authorized requests get a stub 200 ("published (stub)"). `next/tests/http_post_activity.sh` (13 cases).
+- [ ] **8c-post-publish** — Wire authorized POST `/activity` to `outbox:publish` with a server-state context (needs a stateful kernel orchestrator passing logs / actor keys / projection list).
 - [ ] **8d** — Content negotiation by Accept header: application/activity+json (default), application/cbor, application/json, application/sx.
 
 **Deliverables:**
@@ -988,6 +989,7 @@ A few things still under-specified; resolve as work begins.
 Newest first. One line per sub-deliverable commit. Erlang conformance gate
 (`bash lib/erlang/conformance.sh`) must remain 729/729 on every entry.
 
+- **2026-05-28** — Step 8c-post-auth: POST `/activity` route + bearer-token auth via new `route/2(Req, Cfg)` variant. Cfg's `:publish_token` is the expected bearer; mismatched / missing / malformed (no "Bearer " prefix) / empty-token Authorization all surface as 401 `unauthorized_response/0`. `route/1` is a backwards-compatible wrapper with empty Cfg — any POST `/activity` over `route/1` is 401 by design (no token configured). `Bearer ` prefix stripped via the same `match_prefix` helper used elsewhere. Real publish wiring deferred to `8c-post-publish` (needs the kernel orchestrator that holds logs / actor keys / projection list). `next/tests/http_post_activity.sh` 13/13. Erlang conformance 729/729.
 - **2026-05-28** — Step 8c-proj: routes GET `/projections` (list stub returning `projections: (empty)\n`) + GET `/projections/{name}` (state stub returning `projection: \n`). Bare-path list clause dispatches before the prefix clause so `/projections` and `/projections/{name}` are distinguishable. All three dynamic-prefix routes (actors / artifacts / projections) compose cleanly — verified by a single combined-route test asserting all return 200 with distinct prefixes. Registry-backed implementation deferred — needs a running registry process at route time. `next/tests/http_projections.sh` 11/11. Erlang conformance 729/729.
 - **2026-05-28** — Step 8c-art: GET `/artifacts/{cid}` route added on top of `match_prefix`. Single GET dispatch clause now tries `actors_prefix` first, falls through to `artifacts_prefix` — no path collision (different leading bytes). Stub body echoes the CID with `artifact: ` prefix; real artifact-store lookup deferred to later (will key into the registry / genesis bundle). `next/tests/http_artifacts.sh` 9/9 covers happy path, empty-cid 404, POST 404, actor/artifact non-collision, static-route regression. Erlang conformance 729/729.
 - **2026-05-28** — Step 8c-actors-doc: `http_server` extended with `match_prefix/2` — pure byte-level prefix matcher built on Erlang binary pattern matching (`<>`-style head/tail walk). Empty prefix returns `{ok, FullPath}`; non-match returns `nomatch`; exact match returns `{ok, <<>>}`. Wired into a new GET `/actors/{id}` clause that extracts the id suffix and returns it as the body of `actor_doc_response/1` (stub: `actor: \n`). Empty id falls into 404. `/actors/{id}/outbox` deferred to a later step (needs segment splitting beyond prefix). `next/tests/http_actors.sh` 13/13. Erlang conformance 729/729.