From 938e90455ded5244bf1e1983f0038946d42691df Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 23:55:34 +0000 Subject: [PATCH] =?UTF-8?q?identity:=20session=20registry=20=E2=80=94=20ro?= =?UTF-8?q?ute=20by=20id=20and=20(subject,=20client)=20+=20SSO=20fan-out?= =?UTF-8?q?=20(9=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directory process holding (SessionId, Subject, Client, Pid) rows. Answers the SSO probe lookup(Subject, Client) and the fan-out sessions_for(Subject) (one subject, many clients). Routes only — no grant state, decides nothing. Integration-tested: register a live session, route to it, confirm active. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/identity/conformance.sh | 5 ++ lib/identity/registry.sx | 22 ++++++++ lib/identity/scoreboard.json | 7 +-- lib/identity/scoreboard.md | 3 +- lib/identity/tests/registry.sx | 99 ++++++++++++++++++++++++++++++++++ plans/identity-on-sx.md | 9 +++- 6 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 lib/identity/registry.sx create mode 100644 lib/identity/tests/registry.sx diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index 6af6b22f..e9a047c0 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -30,6 +30,7 @@ trap "rm -f $TMPFILE $OUTFILE" EXIT SUITES=( "session|id-session-test-pass|id-session-test-count" "token|id-token-test-pass|id-token-test-count" + "registry|id-registry-test-pass|id-registry-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -43,12 +44,16 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/erlang/runtime.sx") (load "lib/identity/session.sx") (load "lib/identity/token.sx") +(load "lib/identity/registry.sx") (load "lib/identity/tests/session.sx") (load "lib/identity/tests/token.sx") +(load "lib/identity/tests/registry.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) (eval "(list id-token-test-pass id-token-test-count)") +(epoch 102) +(eval "(list id-registry-test-pass id-registry-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/registry.sx b/lib/identity/registry.sx new file mode 100644 index 00000000..60313f4d --- /dev/null +++ b/lib/identity/registry.sx @@ -0,0 +1,22 @@ +;; identity/registry.sx — routes sessions by id and by (subject, client). +;; +;; The registry is the directory that makes SSO possible: one subject can +;; hold many sessions (one per client), and the OAuth machine asks it the +;; single question that drives silent login — \"is there a live session +;; for this subject + this client?\". It stores (SessionId, Subject, +;; Client, Pid) rows and answers: +;; +;; whereis_session(Id) -> {ok, Pid} | {error, not_found} +;; lookup(Subject, Client) -> {ok, Pid} | {error, not_found} (SSO probe) +;; sessions_for(Subject) -> {ok, [SessionId, ...]} (fan-out) +;; +;; The registry only routes — it holds no grant state and decides nothing. +;; Liveness of the routed-to session is that session process's own affair. + +(define + identity-registry-source + "-module(identity_registry).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n register(Reg, SessionId, Subject, Client, Pid) ->\n Reg ! {register, SessionId, Subject, Client, Pid, self()},\n receive {registry_reply, R} -> R end.\n\n whereis_session(Reg, SessionId) ->\n Reg ! {whereis_session, SessionId, self()},\n receive {registry_reply, R} -> R end.\n\n lookup(Reg, Subject, Client) ->\n Reg ! {lookup, Subject, Client, self()},\n receive {registry_reply, R} -> R end.\n\n sessions_for(Reg, Subject) ->\n Reg ! {sessions_for, Subject, self()},\n receive {registry_reply, R} -> R end.\n\n deregister(Reg, SessionId) ->\n Reg ! {deregister, SessionId, self()},\n receive {registry_reply, R} -> R end.\n\n stop(Reg) ->\n Reg ! {stop, self()},\n receive {registry_reply, R} -> R end.\n\n loop(Entries) ->\n receive\n {register, SessionId, Subject, Client, Pid, From} ->\n From ! {registry_reply, ok},\n loop([{SessionId, Subject, Client, Pid} | remove_id(SessionId, Entries)]);\n {whereis_session, SessionId, From} ->\n From ! {registry_reply, find_id(SessionId, Entries)},\n loop(Entries);\n {lookup, Subject, Client, From} ->\n From ! {registry_reply, find_sc(Subject, Client, Entries)},\n loop(Entries);\n {sessions_for, Subject, From} ->\n From ! {registry_reply, {ok, collect_subject(Subject, Entries)}},\n loop(Entries);\n {deregister, SessionId, From} ->\n From ! {registry_reply, ok},\n loop(remove_id(SessionId, Entries));\n {stop, From} ->\n From ! {registry_reply, ok}\n end.\n\n find_id(_, []) -> {error, not_found};\n find_id(Id, [{Sid, _, _, Pid} | Rest]) ->\n case Sid =:= Id of\n true -> {ok, Pid};\n false -> find_id(Id, Rest)\n end.\n\n find_sc(_, _, []) -> {error, not_found};\n find_sc(Subject, Client, [{_, Su, Cl, Pid} | Rest]) ->\n case Su =:= Subject of\n true ->\n case Cl =:= Client of\n true -> {ok, Pid};\n false -> find_sc(Subject, Client, Rest)\n end;\n false -> find_sc(Subject, Client, Rest)\n end.\n\n collect_subject(_, []) -> [];\n collect_subject(Subject, [{Sid, Su, _, _} | Rest]) ->\n case Su =:= Subject of\n true -> [Sid | collect_subject(Subject, Rest)];\n false -> collect_subject(Subject, Rest)\n end.\n\n remove_id(_, []) -> [];\n remove_id(Id, [{Sid, Su, Cl, Pid} | Rest]) ->\n case Sid =:= Id of\n true -> remove_id(Id, Rest);\n false -> [{Sid, Su, Cl, Pid} | remove_id(Id, Rest)]\n end.") + +(define + identity-load-registry! + (fn () (erlang-load-module identity-registry-source))) diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index 53135b39..ad6a7aaf 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,9 +1,10 @@ { "language": "identity", - "total_pass": 20, - "total": 20, + "total_pass": 29, + "total": 29, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, - {"name":"token","pass":9,"total":9,"status":"ok"} + {"name":"token","pass":9,"total":9,"status":"ok"}, + {"name":"registry","pass":9,"total":9,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 94067fbd..21014c1b 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,11 +1,12 @@ # identity-on-sx Scoreboard -**Total: 20 / 20 tests passing** +**Total: 29 / 29 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | session | 11 | 11 | | ✅ | token | 9 | 9 | +| ✅ | registry | 9 | 9 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/registry.sx b/lib/identity/tests/registry.sx new file mode 100644 index 00000000..27dabd7a --- /dev/null +++ b/lib/identity/tests/registry.sx @@ -0,0 +1,99 @@ +;; identity/tests/registry.sx — routing by id and by (subject, client), +;; SSO fan-out (one subject, many clients), and integration with live +;; session processes routed through the registry. + +(define id-registry-test-count 0) +(define id-registry-test-pass 0) +(define id-registry-test-fails (list)) + +(define + id-registry-test + (fn + (name actual expected) + (set! id-registry-test-count (+ id-registry-test-count 1)) + (if + (= actual expected) + (set! id-registry-test-pass (+ id-registry-test-pass 1)) + (append! id-registry-test-fails {:name name :expected expected :actual actual})))) + +(define idr-ev erlang-eval-ast) +(define idrnm (fn (v) (get v :name))) + +(identity-load-session!) +(identity-load-registry!) + +;; ── whereis by session id ──────────────────────────────────────── + +(id-registry-test + "registered session is found by id" + (idrnm + (idr-ev + "Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:whereis_session(Reg, s1) of\n {ok, _} -> found;\n {error, _} -> missing\n end")) + "found") + +(id-registry-test + "unknown session id is not_found, not a crash" + (idrnm + (idr-ev + "Reg = identity_registry:start(),\n case identity_registry:whereis_session(Reg, nope) of\n {ok, _} -> found;\n {error, Why} -> Why\n end")) + "not_found") + +;; ── lookup by (subject, client) — the SSO probe ────────────────── + +(id-registry-test + "lookup finds a session for subject+client" + (idrnm + (idr-ev + "Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:lookup(Reg, alice, web) of\n {ok, _} -> found;\n {error, _} -> missing\n end")) + "found") + +(id-registry-test + "lookup is precise: right subject, wrong client misses" + (idrnm + (idr-ev + "Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:lookup(Reg, alice, cli) of\n {ok, _} -> found;\n {error, _} -> missing\n end")) + "missing") + +;; ── SSO fan-out: one subject, many clients ─────────────────────── + +(id-registry-test + "sessions_for returns all of a subject's sessions" + (idr-ev + "Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n identity_registry:register(Reg, s2, alice, cli, Me),\n identity_registry:register(Reg, s3, bob, web, Me),\n case identity_registry:sessions_for(Reg, alice) of\n {ok, L} -> length(L)\n end") + 2) + +(id-registry-test + "sessions_for an unknown subject is empty" + (idr-ev + "Reg = identity_registry:start(),\n case identity_registry:sessions_for(Reg, ghost) of\n {ok, L} -> length(L)\n end") + 0) + +;; ── re-register replaces the row for that id (no duplicates) ────── + +(id-registry-test + "re-registering an id does not duplicate it" + (idr-ev + "Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:sessions_for(Reg, alice) of\n {ok, L} -> length(L)\n end") + 1) + +;; ── deregister removes routing ─────────────────────────────────── + +(id-registry-test + "deregistered session is no longer found" + (idrnm + (idr-ev + "Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n identity_registry:deregister(Reg, s1),\n case identity_registry:whereis_session(Reg, s1) of\n {ok, _} -> found;\n {error, _} -> missing\n end")) + "missing") + +;; ── integration: route to a live session and look it up ────────── + +(id-registry-test + "routed-to session answers lookup as active" + (idrnm + (idr-ev + "Me = self(),\n Reg = identity_registry:start(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_registry:register(Reg, s1, alice, web, S),\n {ok, Pid} = identity_registry:lookup(Reg, alice, web),\n case identity_session:lookup(Pid) of\n {ok, {_,_,_,St}} -> St;\n {error, St} -> St\n end")) + "active") + +(define + id-registry-test-summary + (str "registry " id-registry-test-pass "/" id-registry-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index c816ede6..c3319474 100644 --- a/plans/identity-on-sx.md +++ b/plans/identity-on-sx.md @@ -19,7 +19,7 @@ through the event log, all authorization questions delegated to `acl-on-sx`. ## Status (rolling) -`bash lib/identity/conformance.sh` → **20/20** (Phase 1: session, token) +`bash lib/identity/conformance.sh` → **29/29** (Phase 1: session, token, registry) ## Ground rules @@ -59,7 +59,7 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) ## Phase 1 — Sessions + tokens - [x] `session.sx` — session process, create/lookup/expire - [x] `token.sx` — issue/introspect/revoke (opaque, grant-backed) -- [ ] `registry.sx` — route by subject/client +- [x] `registry.sx` — route by subject/client - [ ] `api.sx` + tests + scoreboard + conformance.sh ## Phase 2 — OAuth2 flows @@ -78,6 +78,11 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [ ] tests: audit completeness, cross-instance subject mapping ## Progress log +- 2026-06-06 — `registry.sx`: directory process routing sessions by id and + by (subject, client). Answers the SSO probe `lookup(Subject, Client)` and + the fan-out `sessions_for(Subject)` (one subject, many clients). Routes + only — holds no grant state. Integration-tested end-to-end: register a live + session, route to it, confirm it answers active. +9 → 29/29. - 2026-06-06 — `token.sx`: opaque grant-backed tokens. Token = `make_ref` (carries no info); the token table is a process; `introspect` is a live lookup every time so revocation is real (RFC 7009) — a revoked token reads