fed-sx-m1: Step 8c-post-auth — POST /activity bearer-token gate + route/2 + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 29s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 29s
This commit is contained in:
@@ -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 = <<Pre/binary, Name/binary, 10>>,
|
||||
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.
|
||||
|
||||
134
next/tests/http_post_activity.sh
Executable file
134
next/tests/http_post_activity.sh
Executable file
@@ -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="<no output for epoch $epoch>"
|
||||
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 ]
|
||||
@@ -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: <id>` 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: <cid>\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: <name>\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 (`<<B, _/binary>>`-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: <id>\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.
|
||||
|
||||
Reference in New Issue
Block a user