From 05100ef05041718d00fdaf206a0415867d29139c Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 28 May 2026 12:12:30 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m1:=20Step=208c-post-publish-http=20?= =?UTF-8?q?=E2=80=94=20POST=20/activity=20wires=20through=20nx=5Fkernel:pu?= =?UTF-8?q?blish=20+=2010=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next/kernel/http_server.erl | 41 ++++++++++- next/tests/http_publish.sh | 134 ++++++++++++++++++++++++++++++++++++ plans/fed-sx-milestone-1.md | 3 +- 3 files changed, 175 insertions(+), 3 deletions(-) create mode 100755 next/tests/http_publish.sh diff --git a/next/kernel/http_server.erl b/next/kernel/http_server.erl index 781de4a4..d47478b3 100644 --- a/next/kernel/http_server.erl +++ b/next/kernel/http_server.erl @@ -7,7 +7,9 @@ projections_list_path/0, projections_prefix/0, projections_list_response/0, projection_response/1, activity_path/0, unauthorized_response/0, - post_activity_response/0]). + post_activity_response/0, + validation_failed_response/0, + cid_response/1]). %% HTTP request router per design §16.1. %% @@ -199,11 +201,46 @@ post_activity_response() -> handle_post_activity(Req, Cfg) -> case check_bearer(Req, Cfg) of ok -> - post_activity_response(); + publish_if_kernel(Req); {error, _} -> unauthorized_response() end. +%% publish_if_kernel/1 — 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. +publish_if_kernel(Req) -> + case erlang:whereis(nx_kernel) of + undefined -> + post_activity_response(); + _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 + {ok, Cid} -> cid_response(Cid); + _ -> post_activity_response() + 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} ->
diff --git a/next/tests/http_publish.sh b/next/tests/http_publish.sh
new file mode 100755
index 00000000..97eaae66
--- /dev/null
+++ b/next/tests/http_publish.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+# next/tests/http_publish.sh — Step 8c-post-publish-http test.
+#
+# Exercises the HTTP -> nx_kernel publish bridge: authorized
+# POST /activity with the kernel gen_server running gets routed
+# through nx_kernel:publish/1; the response carries the
+# resulting CID. Without the kernel running, the route falls
+# back to the auth-only stub (covered by http_post_activity.sh).
+# 9 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
+
+# Shared prelude: kernel started, auth header, valid request shape.
+PRELUDE='KM = <<1,2,3,4>>, KS = [{key_id,k1},{algorithm,ed25519},{value,KM}], AS = [{public_keys,[[{id,k1},{created,0},{value,KM}]]}], nx_kernel:start_link(alice, KS, AS), 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>>, Cfg = [{publish_token, Token}],'
+
+# Body builder helper appended into each test:
+BUILDREQ='Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}]}, {body, Body}],'
+
+cat > "$TMPFILE" < 200 with body starting with "cid: "
+(epoch 10)
+(eval "(get (erlang-eval-ast \"${PRELUDE} Body = <<104,101,108,108,111>>, ${BUILDREQ} case http_server:route(Req, Cfg) of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<99,105,100,58,32>>, B) =/= nomatch; _ -> false end\") :name)")
+
+;; Log tip advances after authorized POST
+(epoch 11)
+(eval "(erlang-eval-ast \"${PRELUDE} Body = <<104,105>>, ${BUILDREQ} http_server:route(Req, Cfg), nx_kernel:log_tip()\")")
+
+;; Two authorized POSTs -> tip = 2
+(epoch 12)
+(eval "(erlang-eval-ast \"${PRELUDE} Body = <<104,105>>, ${BUILDREQ} http_server:route(Req, Cfg), http_server:route(Req, Cfg), nx_kernel:log_tip()\")")
+
+;; Same POST twice produces two distinct CIDs (next_published counter)
+(epoch 13)
+(eval "(get (erlang-eval-ast \"${PRELUDE} Body = <<104,105>>, ${BUILDREQ} [{status, 200}, _, {body, B1}] = http_server:route(Req, Cfg), [{status, 200}, _, {body, B2}] = http_server:route(Req, Cfg), B1 =/= B2\") :name)")
+
+;; Unauthorized POST does NOT advance the kernel log
+(epoch 14)
+(eval "(erlang-eval-ast \"${PRELUDE} BadAuth = <<66,101,97,114,101,114,32,98,97,100>>, BadReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, BadAuth}]}, {body, <<>>}], http_server:route(BadReq, Cfg), nx_kernel:log_tip()\")")
+
+;; Sig-failure publish surfaces as 422 (when key material doesn't match)
+(epoch 15)
+(eval "(get (erlang-eval-ast \"OtherKM = <<9,9,9,9>>, BadKS = [{key_id,k1},{algorithm,ed25519},{value,OtherKM}], AS = [{public_keys,[[{id,k1},{created,0},{value,<<1,2,3,4>>}]]}], nx_kernel:start_link(alice, BadKS, AS), 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>>, Cfg = [{publish_token, Token}], Body = <<104,105>>, Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}]}, {body, Body}], case http_server:route(Req, Cfg) of [{status, 422} | _] -> ok; _ -> bad end\") :name)")
+
+;; Without the kernel running, the auth-only stub still works
+(epoch 16)
+(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>>, Cfg = [{publish_token, Token}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}]}, {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)")
+
+;; validation_failed_response shape sanity
+(epoch 17)
+(eval "(erlang-eval-ast \"R = http_server:validation_failed_response(), case R of [{status, 422} | _] -> 422; _ -> nope end\")")
+
+;; cid_response wraps a cid with the right prefix
+(epoch 18)
+(eval "(get (erlang-eval-ast \"R = http_server:cid_response(<<102,111,111>>), case R of [_, _, {body, B}] -> B =:= <<99,105,100,58,32,102,111,111,10>>; _ -> false end\") :name)")
+EPOCHS
+
+OUTPUT=$(timeout 240 "$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  8  "http_server loaded"                "http_server"
+check 10  "POST -> 200 with 'cid: '"          "true"
+check 11  "log_tip = 1 after POST"            "1"
+check 12  "two POSTs -> tip = 2"              "2"
+check 13  "same POST -> distinct CIDs"        "true"
+check 14  "unauthorized POST -> tip = 0"      "0"
+check 15  "sig failure -> 422"                "ok"
+check 16  "kernel-absent fallback stub"       "true"
+check 17  "validation_failed_response 422"    "422"
+check 18  "cid_response wraps cid"            "true"
+
+TOTAL=$((PASS+FAIL))
+if [ $FAIL -eq 0 ]; then
+  echo "ok $PASS/$TOTAL next/tests/http_publish.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 5ea66a9d..e03524bc 100644
--- a/plans/fed-sx-milestone-1.md
+++ b/plans/fed-sx-milestone-1.md
@@ -516,7 +516,7 @@ publish(ActorId, ActivityRequest) ->
 - [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).
 - [x] **8c-post-publish-pure** — `next/kernel/nx_kernel.erl` — pure-functional kernel orchestrator. `new/3(ActorId, KeySpec, ActorState)` builds the runtime state; `publish/2(Request, State)` calls `outbox:publish` with a Context derived from state, advances log + next_published on success. `next/tests/nx_kernel_pure.sh` (12 cases).
 - [x] **8c-post-publish-srv** — gen_server wrapper around nx_kernel: `start_link/3`, named-process `publish/1`, `query/0`, `log_tip/0`, `with_projections/1`, `stop/0`. `next/tests/nx_kernel_server.sh` (11 cases). HTTP layer integration follows.
-- [ ] **8c-post-publish-http** — Wire the gen-server-backed `nx_kernel:publish/1` into `http_server` POST `/activity` handler.
+- [x] **8c-post-publish-http** — POST `/activity` handler now calls `nx_kernel:publish/1` when the kernel process is registered; falls back to the existing stub when not. Success → 200 with `cid: \n` body via `cid_response/1`; sig/replay failures → 422 via `validation_failed_response/0`. `next/tests/http_publish.sh` (10 cases).
 - [ ] **8d** — Content negotiation by Accept header: application/activity+json (default), application/cbor, application/json, application/sx.
 
 **Deliverables:**
@@ -991,6 +991,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-publish-http: POST `/activity` handler now bridges into `nx_kernel:publish/1` when the kernel gen_server is registered (`erlang:whereis(nx_kernel) =/= undefined`). On success the response carries the canonical CID via `cid_response/1`; on pipeline failure the response is 422 via `validation_failed_response/0`. When the kernel isn't registered, the handler falls through to the existing 200 stub — preserves backwards compatibility for the auth-only tests in `http_post_activity.sh`. Distinct POSTs produce distinct CIDs (next_published counter in nx_kernel state). Unauthorized POSTs never reach the kernel — log tip stays at 0. `next/tests/http_publish.sh` 10/10. The POST `/activity` → publish → fold loop is now functional end-to-end through the kernel. Erlang conformance 729/729.
 - **2026-05-28** — Step 8c-post-publish-srv: `nx_kernel.erl` extended with gen_server callbacks + named-process API. `start_link/3(ActorId, KeySpec, ActorState)` spawns the worker and registers under the literal `nx_kernel` atom; `publish/1(Request)` calls into `handle_call({publish, Request}, ...)` which delegates to the pure `publish/2` and reflects the new state back into the server. `query/0` returns the full state proplist; `log_tip/0` is a direct accessor; `with_projections/1` mutates the projections list. Same port quirks as Step 5b/7b documented (raw Pid return, no `?MODULE`, processes don't persist across separate `erlang-eval-ast` calls — tests inline start_link with operations). `next/tests/nx_kernel_server.sh` 11/11. Erlang conformance 729/729.
 - **2026-05-28** — Step 8c-post-publish-pure: `next/kernel/nx_kernel.erl` — pure-functional kernel orchestrator that wraps `outbox:publish/2` with a long-lived runtime state. `new/3(ActorId, KeySpec, ActorState)` initialises state with an empty log + monotonic `:next_published` counter. `publish/2(Request, State)` builds the publish Context from state, calls outbox:publish, and on success advances `:log` and increments `:next_published`. The counter solves the "same Request published twice" replay collision — each call gets a distinct `:published` timestamp, so the canonical-bytes CID differs and stage_replay doesn't halt. On failure (e.g. bad key), state is returned unchanged. Step 8c-post-publish split into pure (done) + srv (gen_server wrapper) sub-deliverables. `next/tests/nx_kernel_pure.sh` 12/12. Erlang conformance 729/729.
 - **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.