Compare commits
14 Commits
loops/iden
...
loops/even
| Author | SHA1 | Date | |
|---|---|---|---|
| 29127d8613 | |||
| 80174c7197 | |||
| f6c1d1e9bf | |||
| e35769411e | |||
| 05d5c46730 | |||
| 7153e742c8 | |||
| 24d4db3f0d | |||
| 9adeff1431 | |||
| 80a2dee22f | |||
| 15e9503b05 | |||
| 4674b797cb | |||
| 540933bfca | |||
| 70aea21601 | |||
| 797c5f9147 |
@@ -1 +1 @@
|
|||||||
{"sessionId":"bf20a443-9df8-4cb9-932e-8c6f4c4625c2","pid":1303602,"procStart":"253831081","acquiredAt":1779865895644}
|
{"sessionId":"c4d97db1-361c-4a04-a99b-c838f9385469","pid":2426590,"procStart":"349789073","acquiredAt":1780789990975}
|
||||||
251
lib/events/api.sx
Normal file
251
lib/events/api.sx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
;; lib/events/api.sx — public events surface over calendar + availability.
|
||||||
|
;;
|
||||||
|
;; A `store` is an immutable value holding scheduled events and (in-memory)
|
||||||
|
;; bookings:
|
||||||
|
;;
|
||||||
|
;; {:events (event ...) :bookings ((actor key) ...)}
|
||||||
|
;;
|
||||||
|
;; The in-memory `:bookings` list supports pure, value-level queries. The
|
||||||
|
;; DURABLE booking path (ev/*-occ! and ev/*-p) keeps bookings in persist
|
||||||
|
;; streams via booking.sx — capacity-safe, cancellable, replayable — and
|
||||||
|
;; derives availability from those streams. Use the persist path for real
|
||||||
|
;; bookings; the in-memory path for projections and tests.
|
||||||
|
;;
|
||||||
|
;; All queries are windowed: agenda/free/next-free expand recurring events into
|
||||||
|
;; concrete occurrences within an explicit (or derived) window before running
|
||||||
|
;; the Datalog availability rules.
|
||||||
|
|
||||||
|
(define ev/store (fn (events bookings) {:bookings bookings :events events}))
|
||||||
|
|
||||||
|
(define ev/empty (fn () (ev/store (list) (list))))
|
||||||
|
|
||||||
|
(define ev/events (fn (store) (get store :events)))
|
||||||
|
(define ev/bookings (fn (store) (get store :bookings)))
|
||||||
|
|
||||||
|
;; Add a (constructed) event to the store.
|
||||||
|
(define
|
||||||
|
ev/add-event
|
||||||
|
(fn
|
||||||
|
(store event)
|
||||||
|
(ev/store (cons event (ev/events store)) (ev/bookings store))))
|
||||||
|
|
||||||
|
;; Schedule a fresh event from parts, returning the updated store. rrule may be
|
||||||
|
;; nil for a one-off. (Booking is separate — see ev/book.)
|
||||||
|
(define
|
||||||
|
ev/schedule
|
||||||
|
(fn
|
||||||
|
(store id dtstart duration rrule capacity)
|
||||||
|
(ev/add-event store (ev-event id dtstart duration rrule capacity))))
|
||||||
|
|
||||||
|
;; Record that `actor` holds the occurrence with `key` (in-memory only — see
|
||||||
|
;; ev/book-occ! for the durable, capacity-safe path).
|
||||||
|
(define
|
||||||
|
ev/book
|
||||||
|
(fn
|
||||||
|
(store actor key)
|
||||||
|
(ev/store
|
||||||
|
(ev/events store)
|
||||||
|
(cons (list actor key) (ev/bookings store)))))
|
||||||
|
|
||||||
|
;; The event with `id`, or nil.
|
||||||
|
(define
|
||||||
|
ev/event-by-id
|
||||||
|
(fn
|
||||||
|
(store id)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(found ev)
|
||||||
|
(if (nil? found) (if (= (get ev :id) id) ev found) found))
|
||||||
|
nil
|
||||||
|
(ev/events store))))
|
||||||
|
|
||||||
|
;; Capacity of the event an occurrence belongs to (0 if unknown).
|
||||||
|
(define
|
||||||
|
ev/capacity-of
|
||||||
|
(fn
|
||||||
|
(store occ)
|
||||||
|
(let
|
||||||
|
((ev (ev/event-by-id store (get occ :id))))
|
||||||
|
(if (nil? ev) 0 (get ev :capacity)))))
|
||||||
|
|
||||||
|
;; The maximum event duration in the store (0 when empty) — used to widen
|
||||||
|
;; expansion windows so any occurrence overlapping a query is captured.
|
||||||
|
(define
|
||||||
|
ev/store-max-duration
|
||||||
|
(fn
|
||||||
|
(store)
|
||||||
|
(reduce
|
||||||
|
(fn (m ev) (max m (get ev :duration)))
|
||||||
|
0
|
||||||
|
(ev/events store))))
|
||||||
|
|
||||||
|
;; All occurrences across all events within [ws, we), ascending by start.
|
||||||
|
(define
|
||||||
|
ev/agenda
|
||||||
|
(fn (store ws we) (ev-expand-all (ev/events store) ws we)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-key-member?
|
||||||
|
(fn
|
||||||
|
(k keys)
|
||||||
|
(cond
|
||||||
|
((empty? keys) false)
|
||||||
|
((= k (first keys)) true)
|
||||||
|
(else (ev-key-member? k (rest keys))))))
|
||||||
|
|
||||||
|
;; Occurrence keys `actor` has booked (in-memory store).
|
||||||
|
(define
|
||||||
|
ev/actor-keys
|
||||||
|
(fn
|
||||||
|
(store actor)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc b)
|
||||||
|
(if (= (first b) actor) (cons (first (rest b)) acc) acc))
|
||||||
|
(list)
|
||||||
|
(ev/bookings store))))
|
||||||
|
|
||||||
|
;; The agenda restricted to occurrences `actor` is booked into (in-memory).
|
||||||
|
(define
|
||||||
|
ev/agenda-for
|
||||||
|
(fn
|
||||||
|
(store actor ws we)
|
||||||
|
(let
|
||||||
|
((keys (ev/actor-keys store actor)))
|
||||||
|
(filter
|
||||||
|
(fn (o) (ev-key-member? (ev-occ-key o) keys))
|
||||||
|
(ev/agenda store ws we)))))
|
||||||
|
|
||||||
|
;; Build an availability db over occurrences expanded in [ws, we) using the
|
||||||
|
;; in-memory bookings.
|
||||||
|
(define
|
||||||
|
ev/avail-window-db
|
||||||
|
(fn
|
||||||
|
(store ws we)
|
||||||
|
(ev-avail-db (ev/agenda store ws we) (ev/bookings store))))
|
||||||
|
|
||||||
|
;; Is `actor` free across [qs, qe)? Expands a window wide enough (back by the
|
||||||
|
;; longest event) to capture any occurrence that could overlap.
|
||||||
|
(define
|
||||||
|
ev/free?
|
||||||
|
(fn
|
||||||
|
(store actor qs qe)
|
||||||
|
(ev-free?
|
||||||
|
(ev/avail-window-db store (- qs (ev/store-max-duration store)) qe)
|
||||||
|
actor
|
||||||
|
qs
|
||||||
|
qe)))
|
||||||
|
|
||||||
|
;; Earliest free slot of `duration` for `actor` in [after, horizon), or nil.
|
||||||
|
(define
|
||||||
|
ev/next-free
|
||||||
|
(fn
|
||||||
|
(store actor after duration horizon)
|
||||||
|
(ev-next-free
|
||||||
|
(ev/avail-window-db
|
||||||
|
store
|
||||||
|
(- after (ev/store-max-duration store))
|
||||||
|
horizon)
|
||||||
|
actor
|
||||||
|
after
|
||||||
|
duration
|
||||||
|
horizon)))
|
||||||
|
|
||||||
|
;; Overlapping double-bookings for `actor` among occurrences in [ws, we).
|
||||||
|
(define
|
||||||
|
ev/conflicts
|
||||||
|
(fn
|
||||||
|
(store actor ws we)
|
||||||
|
(ev-conflicts (ev/avail-window-db store ws we) actor)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev/has-conflict?
|
||||||
|
(fn
|
||||||
|
(store actor ws we)
|
||||||
|
(> (len (ev/conflicts store actor ws we)) 0)))
|
||||||
|
|
||||||
|
;; ---- durable, persist-backed booking path ----
|
||||||
|
;; These take a persist backend `b` (persist/open) plus the schedule `store`.
|
||||||
|
;; Bookings live in per-occurrence streams (booking.sx); availability is derived
|
||||||
|
;; by replaying those streams for the occurrences in the query window.
|
||||||
|
|
||||||
|
;; Durably book `actor` into occurrence `occ` (dict {:id :start :end}),
|
||||||
|
;; capacity-safe. Returns the booking.sx result (:booked / :full / :already).
|
||||||
|
(define
|
||||||
|
ev/book-occ!
|
||||||
|
(fn
|
||||||
|
(b store actor occ)
|
||||||
|
(ev/book! b (ev-occ-key occ) (ev/capacity-of store occ) actor)))
|
||||||
|
|
||||||
|
;; Durably cancel `actor`'s seat on `occ`, freeing capacity.
|
||||||
|
(define
|
||||||
|
ev/cancel-occ!
|
||||||
|
(fn (b store actor occ) (ev/cancel! b (ev-occ-key occ) actor)))
|
||||||
|
|
||||||
|
;; Live roster / seats-left for a specific occurrence from persist.
|
||||||
|
(define ev/roster-occ (fn (b occ) (ev/roster b (ev-occ-key occ))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev/seats-left-occ
|
||||||
|
(fn
|
||||||
|
(b store occ)
|
||||||
|
(ev/seats-left b (ev-occ-key occ) (ev/capacity-of store occ))))
|
||||||
|
|
||||||
|
;; Derive (actor key) booking pairs from the persist rosters of `occs`.
|
||||||
|
(define
|
||||||
|
ev/persist-bookings
|
||||||
|
(fn
|
||||||
|
(b occs)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc occ)
|
||||||
|
(let
|
||||||
|
((key (ev-occ-key occ)))
|
||||||
|
(append
|
||||||
|
acc
|
||||||
|
(map (fn (actor) (list actor key)) (ev/roster b key)))))
|
||||||
|
(list)
|
||||||
|
occs)))
|
||||||
|
|
||||||
|
;; Availability db over [ws, we) with bookings sourced from persist streams.
|
||||||
|
(define
|
||||||
|
ev/avail-db-p
|
||||||
|
(fn
|
||||||
|
(b store ws we)
|
||||||
|
(let
|
||||||
|
((occs (ev/agenda store ws we)))
|
||||||
|
(ev-avail-db occs (ev/persist-bookings b occs)))))
|
||||||
|
|
||||||
|
;; Persist-backed availability queries (mirror the in-memory ev/free? etc).
|
||||||
|
(define
|
||||||
|
ev/free-p?
|
||||||
|
(fn
|
||||||
|
(b store actor qs qe)
|
||||||
|
(ev-free?
|
||||||
|
(ev/avail-db-p b store (- qs (ev/store-max-duration store)) qe)
|
||||||
|
actor
|
||||||
|
qs
|
||||||
|
qe)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev/next-free-p
|
||||||
|
(fn
|
||||||
|
(b store actor after duration horizon)
|
||||||
|
(ev-next-free
|
||||||
|
(ev/avail-db-p b store (- after (ev/store-max-duration store)) horizon)
|
||||||
|
actor
|
||||||
|
after
|
||||||
|
duration
|
||||||
|
horizon)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev/conflicts-p
|
||||||
|
(fn
|
||||||
|
(b store actor ws we)
|
||||||
|
(ev-conflicts (ev/avail-db-p b store ws we) actor)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev/has-conflict-p?
|
||||||
|
(fn
|
||||||
|
(b store actor ws we)
|
||||||
|
(> (len (ev/conflicts-p b store actor ws we)) 0)))
|
||||||
177
lib/events/availability.sx
Normal file
177
lib/events/availability.sx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
;; lib/events/availability.sx — free/busy + conflict detection on Datalog.
|
||||||
|
;;
|
||||||
|
;; Availability is per-actor and is forward-chained Datalog over two EDB
|
||||||
|
;; relations:
|
||||||
|
;;
|
||||||
|
;; (occurrence Key EventId Start End) ; an expanded calendar occurrence
|
||||||
|
;; (booking Actor Key) ; actor attends/holds that occurrence
|
||||||
|
;;
|
||||||
|
;; The derived relations are the whole policy:
|
||||||
|
;;
|
||||||
|
;; busy(A,S,E) — A is committed for [S,E) (a booked occurrence)
|
||||||
|
;; conflict(A,O1,O2) — A double-booked into two overlapping occurrences
|
||||||
|
;; busy_in(A,QS,QE) — A is busy somewhere inside query window [QS,QE)
|
||||||
|
;;
|
||||||
|
;; Intervals are half-open [Start,End) in epoch minutes (see calendar.sx), so
|
||||||
|
;; adjacent slots (E == next start) do NOT conflict. Conflict pairs are
|
||||||
|
;; canonical (O1 < O2 by key) so each overlap is reported once. The same `busy`
|
||||||
|
;; rule answers "is A free in [QS,QE)?" (busy_in is empty) and feeds "when is A
|
||||||
|
;; next free?" (ev-next-free probes candidate slots with the same rule).
|
||||||
|
|
||||||
|
;; A stable key for an occurrence dict {:id :start :end}.
|
||||||
|
(define ev-occ-key (fn (occ) (str (get occ :id) "@" (get occ :start))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-occurrence-fact
|
||||||
|
(fn
|
||||||
|
(occ)
|
||||||
|
(list
|
||||||
|
(quote occurrence)
|
||||||
|
(ev-occ-key occ)
|
||||||
|
(get occ :id)
|
||||||
|
(get occ :start)
|
||||||
|
(get occ :end))))
|
||||||
|
|
||||||
|
(define ev-occurrence-facts (fn (occs) (map ev-occurrence-fact occs)))
|
||||||
|
|
||||||
|
(define ev-booking-fact (fn (actor key) (list (quote booking) actor key)))
|
||||||
|
|
||||||
|
(define ev-qwindow-fact (fn (qs qe) (list (quote qwindow) qs qe)))
|
||||||
|
|
||||||
|
;; Range restriction: each comparison's variables are bound by an earlier
|
||||||
|
;; positive literal (qwindow / busy precede the < tests). Conflict uses
|
||||||
|
;; (< O1 O2) on the keys so each overlapping pair is reported once.
|
||||||
|
(define
|
||||||
|
ev-avail-rules
|
||||||
|
(quote
|
||||||
|
((busy A S E <- (booking A O) (occurrence O _ S E))
|
||||||
|
(conflict
|
||||||
|
A
|
||||||
|
O1
|
||||||
|
O2
|
||||||
|
<-
|
||||||
|
(booking A O1)
|
||||||
|
(booking A O2)
|
||||||
|
(occurrence O1 _ S1 E1)
|
||||||
|
(occurrence O2 _ S2 E2)
|
||||||
|
(< O1 O2)
|
||||||
|
(< S1 E2)
|
||||||
|
(< S2 E1))
|
||||||
|
(busy_in A QS QE <- (qwindow QS QE) (busy A S E) (< S QE) (< QS E)))))
|
||||||
|
|
||||||
|
;; Build a Datalog db from EDB facts under the availability ruleset.
|
||||||
|
(define ev-build-avail (fn (facts) (dl-program-data facts ev-avail-rules)))
|
||||||
|
|
||||||
|
;; Convenience: build a db from occurrence dicts + booking pairs.
|
||||||
|
;; bookings is a list of (actor key) pairs.
|
||||||
|
(define
|
||||||
|
ev-avail-db
|
||||||
|
(fn
|
||||||
|
(occs bookings)
|
||||||
|
(ev-build-avail
|
||||||
|
(append
|
||||||
|
(ev-occurrence-facts occs)
|
||||||
|
(map
|
||||||
|
(fn (b) (ev-booking-fact (first b) (first (rest b))))
|
||||||
|
bookings)))))
|
||||||
|
|
||||||
|
;; Helper: insertion sort a list of (S E ...) lists ascending by S then E.
|
||||||
|
(define
|
||||||
|
ev-list-before?
|
||||||
|
(fn
|
||||||
|
(a b)
|
||||||
|
(cond
|
||||||
|
((< (first a) (first b)) true)
|
||||||
|
((> (first a) (first b)) false)
|
||||||
|
(else (< (first (rest a)) (first (rest b)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-list-insert
|
||||||
|
(fn
|
||||||
|
(x sorted)
|
||||||
|
(cond
|
||||||
|
((empty? sorted) (list x))
|
||||||
|
((ev-list-before? x (first sorted)) (cons x sorted))
|
||||||
|
(else (cons (first sorted) (ev-list-insert x (rest sorted)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-sort-lists
|
||||||
|
(fn (xs) (reduce (fn (acc x) (ev-list-insert x acc)) (list) xs)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-dedup-sorted
|
||||||
|
(fn
|
||||||
|
(xs)
|
||||||
|
(cond
|
||||||
|
((empty? xs) xs)
|
||||||
|
((empty? (rest xs)) xs)
|
||||||
|
((= (first xs) (first (rest xs))) (ev-dedup-sorted (rest xs)))
|
||||||
|
(else (cons (first xs) (ev-dedup-sorted (rest xs)))))))
|
||||||
|
|
||||||
|
;; All busy intervals (list S E) for an actor, ascending by start.
|
||||||
|
(define
|
||||||
|
ev-busy
|
||||||
|
(fn
|
||||||
|
(db actor)
|
||||||
|
(let
|
||||||
|
((rows (dl-query db (list (quote busy) actor (quote S) (quote E)))))
|
||||||
|
(ev-sort-lists (map (fn (b) (list (get b :S) (get b :E))) rows)))))
|
||||||
|
|
||||||
|
;; Distinct conflicting occurrence-key pairs for an actor (each pair once).
|
||||||
|
(define
|
||||||
|
ev-conflicts
|
||||||
|
(fn
|
||||||
|
(db actor)
|
||||||
|
(dl-query db (list (quote conflict) actor (quote O1) (quote O2)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-has-conflict?
|
||||||
|
(fn (db actor) (> (len (ev-conflicts db actor)) 0)))
|
||||||
|
|
||||||
|
;; Is `actor` free across the whole window [qs,qe)? (no booked occurrence
|
||||||
|
;; overlaps it). Asserts a transient qwindow fact, queries, retracts.
|
||||||
|
(define
|
||||||
|
ev-free?
|
||||||
|
(fn
|
||||||
|
(db actor qs qe)
|
||||||
|
(do
|
||||||
|
(dl-assert! db (ev-qwindow-fact qs qe))
|
||||||
|
(let
|
||||||
|
((rows (dl-query db (list (quote busy_in) actor (quote QS) (quote QE)))))
|
||||||
|
(begin (dl-retract! db (ev-qwindow-fact qs qe)) (empty? rows))))))
|
||||||
|
|
||||||
|
;; ---- next-free slot search ----
|
||||||
|
;; The earliest start s >= `after` such that [s, s+duration) is entirely free
|
||||||
|
;; for `actor` and ends at or before `horizon`, or nil if none. The earliest
|
||||||
|
;; such slot must begin either at `after` or immediately after some busy
|
||||||
|
;; interval ends (classic interval packing), so those are the only candidates
|
||||||
|
;; we probe — each probe reuses the busy_in rule via ev-free?.
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-first-free
|
||||||
|
(fn
|
||||||
|
(db actor cands duration horizon)
|
||||||
|
(cond
|
||||||
|
((empty? cands) nil)
|
||||||
|
(else
|
||||||
|
(let
|
||||||
|
((s (first cands)))
|
||||||
|
(if
|
||||||
|
(and
|
||||||
|
(<= (+ s duration) horizon)
|
||||||
|
(ev-free? db actor s (+ s duration)))
|
||||||
|
s
|
||||||
|
(ev-first-free db actor (rest cands) duration horizon)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-next-free
|
||||||
|
(fn
|
||||||
|
(db actor after duration horizon)
|
||||||
|
(let
|
||||||
|
((ends (filter (fn (e) (>= e after)) (map (fn (iv) (first (rest iv))) (ev-busy db actor)))))
|
||||||
|
(ev-first-free
|
||||||
|
db
|
||||||
|
actor
|
||||||
|
(ev-dedup-sorted (sort (cons after ends)))
|
||||||
|
duration
|
||||||
|
horizon))))
|
||||||
271
lib/events/booking.sx
Normal file
271
lib/events/booking.sx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
;; lib/events/booking.sx — transactional, capacity-safe booking on persist.
|
||||||
|
;;
|
||||||
|
;; Each bookable occurrence has an append-only stream of booking events:
|
||||||
|
;;
|
||||||
|
;; :booking free booking — actor immediately holds a confirmed seat
|
||||||
|
;; :hold provisional hold — seat reserved while payment is pending
|
||||||
|
;; :confirm a held seat becomes confirmed (payment succeeded)
|
||||||
|
;; :release a held seat is abandoned (payment failed/expired) — seat freed
|
||||||
|
;; :cancel a held or confirmed seat is given up — seat freed
|
||||||
|
;;
|
||||||
|
;; The live state is the stream FOLDED in order into per-actor seat states
|
||||||
|
;; (:held / :confirmed); an actor in ANY state occupies a seat, so both held and
|
||||||
|
;; confirmed seats count toward capacity — a pending payment cannot be
|
||||||
|
;; oversold. A freed seat (release/cancel) reopens capacity.
|
||||||
|
;;
|
||||||
|
;; Capacity safety is the contract: two writers racing for the last seat must
|
||||||
|
;; NEVER both succeed. Seat-ACQUIRING writes (:booking, :hold) go through
|
||||||
|
;; persist's optimistic concurrency — `persist/append-expect` appends only if
|
||||||
|
;; the stream's last-seq still equals what the writer observed; else it returns
|
||||||
|
;; a conflict the writer retries. Seat-FREEING writes (:cancel, :release) and
|
||||||
|
;; the state transition (:confirm) never oversell, so they append directly.
|
||||||
|
|
||||||
|
(define ev-booking-stream (fn (occ-key) (str "booking:" occ-key)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-bk-member?
|
||||||
|
(fn
|
||||||
|
(x xs)
|
||||||
|
(cond
|
||||||
|
((empty? xs) false)
|
||||||
|
((= x (first xs)) true)
|
||||||
|
(else (ev-bk-member? x (rest xs))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-bk-index
|
||||||
|
(fn
|
||||||
|
(xs x i)
|
||||||
|
(cond
|
||||||
|
((empty? xs) -1)
|
||||||
|
((= (first xs) x) i)
|
||||||
|
(else (ev-bk-index (rest xs) x (+ i 1))))))
|
||||||
|
|
||||||
|
;; ---- per-actor state association list: ((actor state) ...) in join order ----
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-state-has?
|
||||||
|
(fn
|
||||||
|
(states actor)
|
||||||
|
(cond
|
||||||
|
((empty? states) false)
|
||||||
|
((= (first (first states)) actor) true)
|
||||||
|
(else (ev-state-has? (rest states) actor)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-state-get
|
||||||
|
(fn
|
||||||
|
(states actor)
|
||||||
|
(cond
|
||||||
|
((empty? states) :none)
|
||||||
|
((= (first (first states)) actor) (first (rest (first states))))
|
||||||
|
(else (ev-state-get (rest states) actor)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-state-del
|
||||||
|
(fn (states actor) (filter (fn (p) (not (= (first p) actor))) states)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-state-set
|
||||||
|
(fn
|
||||||
|
(states actor st)
|
||||||
|
(if
|
||||||
|
(ev-state-has? states actor)
|
||||||
|
(map (fn (p) (if (= (first p) actor) (list actor st) p)) states)
|
||||||
|
(append states (list (list actor st))))))
|
||||||
|
|
||||||
|
;; Fold the booking stream into per-actor seat states (join order preserved).
|
||||||
|
(define
|
||||||
|
ev-fold-states
|
||||||
|
(fn
|
||||||
|
(events)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc e)
|
||||||
|
(let
|
||||||
|
((typ (persist/event-type e))
|
||||||
|
(actor (get (persist/event-data e) :actor)))
|
||||||
|
(cond
|
||||||
|
((= typ :booking) (ev-state-set acc actor :confirmed))
|
||||||
|
((= typ :hold) (ev-state-set acc actor :held))
|
||||||
|
((= typ :confirm)
|
||||||
|
(if
|
||||||
|
(ev-state-has? acc actor)
|
||||||
|
(ev-state-set acc actor :confirmed)
|
||||||
|
acc))
|
||||||
|
((= typ :cancel) (ev-state-del acc actor))
|
||||||
|
((= typ :release) (ev-state-del acc actor))
|
||||||
|
(else acc))))
|
||||||
|
(list)
|
||||||
|
events)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-states-of
|
||||||
|
(fn
|
||||||
|
(b occ-key)
|
||||||
|
(ev-fold-states (persist/read b (ev-booking-stream occ-key)))))
|
||||||
|
|
||||||
|
;; Live roster (actors holding a seat — held or confirmed), oldest active first.
|
||||||
|
(define
|
||||||
|
ev-booked-actors
|
||||||
|
(fn (b occ-key) (map (fn (p) (first p)) (ev-states-of b occ-key))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-actor-booked?
|
||||||
|
(fn (b occ-key actor) (ev-bk-member? actor (ev-booked-actors b occ-key))))
|
||||||
|
|
||||||
|
;; Live seat count (folded roster size — both held and confirmed seats).
|
||||||
|
(define
|
||||||
|
ev-booking-count
|
||||||
|
(fn (b occ-key) (len (ev-booked-actors b occ-key))))
|
||||||
|
|
||||||
|
;; Seat state for an actor: :held / :confirmed / :none.
|
||||||
|
(define
|
||||||
|
ev/seat-state
|
||||||
|
(fn (b occ-key actor) (ev-state-get (ev-states-of b occ-key) actor)))
|
||||||
|
|
||||||
|
;; 1-based seat number for an actor on the roster (0 if not booked).
|
||||||
|
(define
|
||||||
|
ev-seat-of
|
||||||
|
(fn
|
||||||
|
(actors actor)
|
||||||
|
(let
|
||||||
|
((i (ev-bk-index actors actor 0)))
|
||||||
|
(if (< i 0) 0 (+ i 1)))))
|
||||||
|
|
||||||
|
;; ---- seat-acquiring writes (capacity-guarded via append-expect) ----
|
||||||
|
|
||||||
|
;; One seat-acquiring attempt of `kind` (:booking or :hold) against an OBSERVED
|
||||||
|
;; snapshot (roster the writer saw + the last-seq). Returns :already / :full /
|
||||||
|
;; :conflict, or a success dict tagged with `ok-status`. :conflict means a
|
||||||
|
;; concurrent append landed since the snapshot — the caller must re-observe.
|
||||||
|
(define
|
||||||
|
ev-acquire-with-observed
|
||||||
|
(fn
|
||||||
|
(b occ-key capacity actor observed-actors expected kind ok-status)
|
||||||
|
(cond
|
||||||
|
((ev-bk-member? actor observed-actors) {:seat (ev-seat-of observed-actors actor) :actor actor :status :already})
|
||||||
|
((>= (len observed-actors) capacity) {:actor actor :capacity capacity :status :full})
|
||||||
|
(else
|
||||||
|
(let
|
||||||
|
((r (persist/append-expect b (ev-booking-stream occ-key) expected kind 0 {:actor actor})))
|
||||||
|
(if (persist/conflict? r) {:actual (persist/conflict-actual r) :actor actor :status :conflict} {:seat (+ (len observed-actors) 1) :actor actor :status ok-status}))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-acquire!
|
||||||
|
(fn
|
||||||
|
(b occ-key capacity actor kind ok-status)
|
||||||
|
(let
|
||||||
|
((res (ev-acquire-with-observed b occ-key capacity actor (ev-booked-actors b occ-key) (persist/last-seq b (ev-booking-stream occ-key)) kind ok-status)))
|
||||||
|
(if
|
||||||
|
(= (get res :status) :conflict)
|
||||||
|
(ev-acquire! b occ-key capacity actor kind ok-status)
|
||||||
|
res))))
|
||||||
|
|
||||||
|
;; Capacity-safe confirmed booking (retrying on conflict).
|
||||||
|
(define
|
||||||
|
ev/book!
|
||||||
|
(fn
|
||||||
|
(b occ-key capacity actor)
|
||||||
|
(ev-acquire! b occ-key capacity actor :booking :booked)))
|
||||||
|
|
||||||
|
;; Capacity-safe provisional hold (retrying on conflict). The seat is reserved
|
||||||
|
;; (counts toward capacity) until confirmed or released.
|
||||||
|
(define
|
||||||
|
ev/hold!
|
||||||
|
(fn
|
||||||
|
(b occ-key capacity actor)
|
||||||
|
(ev-acquire! b occ-key capacity actor :hold :held)))
|
||||||
|
|
||||||
|
;; Test seam: one attempt against a caller-supplied snapshot (book or hold).
|
||||||
|
(define
|
||||||
|
ev/book-with-observed
|
||||||
|
(fn
|
||||||
|
(b occ-key capacity actor observed-actors expected)
|
||||||
|
(ev-acquire-with-observed
|
||||||
|
b
|
||||||
|
occ-key
|
||||||
|
capacity
|
||||||
|
actor
|
||||||
|
observed-actors
|
||||||
|
expected
|
||||||
|
:booking :booked)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev/hold-with-observed
|
||||||
|
(fn
|
||||||
|
(b occ-key capacity actor observed-actors expected)
|
||||||
|
(ev-acquire-with-observed
|
||||||
|
b
|
||||||
|
occ-key
|
||||||
|
capacity
|
||||||
|
actor
|
||||||
|
observed-actors
|
||||||
|
expected
|
||||||
|
:hold :held)))
|
||||||
|
|
||||||
|
;; ---- state transitions / seat-freeing writes (no oversell, append direct) ----
|
||||||
|
|
||||||
|
;; Confirm a held seat (payment succeeded). :confirmed on success,
|
||||||
|
;; :already-confirmed if it was confirmed, :not-held otherwise.
|
||||||
|
(define
|
||||||
|
ev/confirm!
|
||||||
|
(fn
|
||||||
|
(b occ-key actor)
|
||||||
|
(let
|
||||||
|
((st (ev/seat-state b occ-key actor)))
|
||||||
|
(cond
|
||||||
|
((= st :held)
|
||||||
|
(begin
|
||||||
|
(persist/append
|
||||||
|
b
|
||||||
|
(ev-booking-stream occ-key)
|
||||||
|
:confirm 0
|
||||||
|
{:actor actor})
|
||||||
|
{:actor actor :status :confirmed}))
|
||||||
|
((= st :confirmed) {:actor actor :status :already-confirmed})
|
||||||
|
(else {:actor actor :status :not-held})))))
|
||||||
|
|
||||||
|
;; Release a held seat (payment failed/expired), freeing it. Only valid for a
|
||||||
|
;; held seat — confirmed bookings are given up via ev/cancel!.
|
||||||
|
(define
|
||||||
|
ev/release!
|
||||||
|
(fn
|
||||||
|
(b occ-key actor)
|
||||||
|
(let
|
||||||
|
((st (ev/seat-state b occ-key actor)))
|
||||||
|
(if
|
||||||
|
(= st :held)
|
||||||
|
(begin
|
||||||
|
(persist/append
|
||||||
|
b
|
||||||
|
(ev-booking-stream occ-key)
|
||||||
|
:release 0
|
||||||
|
{:actor actor})
|
||||||
|
{:actor actor :status :released})
|
||||||
|
{:actor actor :status :not-held}))))
|
||||||
|
|
||||||
|
;; Cancel a held or confirmed seat, freeing it. :cancelled or :not-booked.
|
||||||
|
(define
|
||||||
|
ev/cancel!
|
||||||
|
(fn
|
||||||
|
(b occ-key actor)
|
||||||
|
(if
|
||||||
|
(ev-bk-member? actor (ev-booked-actors b occ-key))
|
||||||
|
(begin
|
||||||
|
(persist/append
|
||||||
|
b
|
||||||
|
(ev-booking-stream occ-key)
|
||||||
|
:cancel 0
|
||||||
|
{:actor actor})
|
||||||
|
{:actor actor :status :cancelled})
|
||||||
|
{:actor actor :status :not-booked})))
|
||||||
|
|
||||||
|
;; The roster as a plain list of actors (oldest active first).
|
||||||
|
(define ev/roster (fn (b occ-key) (ev-booked-actors b occ-key)))
|
||||||
|
|
||||||
|
;; Seats remaining for an occurrence of the given capacity.
|
||||||
|
(define
|
||||||
|
ev/seats-left
|
||||||
|
(fn
|
||||||
|
(b occ-key capacity)
|
||||||
|
(max 0 (- capacity (ev-booking-count b occ-key)))))
|
||||||
468
lib/events/calendar.sx
Normal file
468
lib/events/calendar.sx
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
;; lib/events/calendar.sx — civil date arithmetic + RRULE expansion in a window.
|
||||||
|
;;
|
||||||
|
;; Datetimes are integer "epoch minutes": days-since-1970-01-01 * 1440 plus
|
||||||
|
;; minute-of-day. Ordering, window bounds, and durations are plain integer
|
||||||
|
;; arithmetic. Civil <-> day-number conversion uses Howard Hinnant's algorithm
|
||||||
|
;; (exact, branch-free, correct for the proleptic Gregorian calendar).
|
||||||
|
;;
|
||||||
|
;; RRULE expansion is the bridge to Datalog: a recurring event expands to a
|
||||||
|
;; bounded list of occurrence dicts within an explicit (win-start, win-end)
|
||||||
|
;; window. Expansion is ALWAYS windowed — an RRULE without a window is an
|
||||||
|
;; infinite computation and is never permitted. Supported subset (RFC 5545):
|
||||||
|
;; FREQ=DAILY|WEEKLY|MONTHLY, INTERVAL, COUNT, UNTIL, BYDAY (weekly: weekday
|
||||||
|
;; numbers; monthly: {:ord N :wd W} ordinal weekdays), BYMONTHDAY (monthly,
|
||||||
|
;; negative = from month end). YEARLY and the rest are deferred.
|
||||||
|
|
||||||
|
;; ---- integer helpers ----
|
||||||
|
|
||||||
|
;; Floored integer division (modulo is already floored, so the remainder
|
||||||
|
;; subtraction makes the quotient exact and floor-correct for any sign).
|
||||||
|
(define ev-floor-div (fn (a b) (quotient (- a (modulo a b)) b)))
|
||||||
|
|
||||||
|
(define ev-or (fn (x d) (if (nil? x) d x)))
|
||||||
|
|
||||||
|
(define ev-filter-nil (fn (xs) (filter (fn (x) (not (nil? x))) xs)))
|
||||||
|
|
||||||
|
;; ---- civil date core (Hinnant) ----
|
||||||
|
|
||||||
|
;; Days since 1970-01-01 for civil (y, m, d). m in [1,12], d in [1,31].
|
||||||
|
(define
|
||||||
|
ev-days-from-civil
|
||||||
|
(fn
|
||||||
|
(y0 m d)
|
||||||
|
(let
|
||||||
|
((y (if (<= m 2) (- y0 1) y0)))
|
||||||
|
(let
|
||||||
|
((era (ev-floor-div (if (>= y 0) y (- y 399)) 400)))
|
||||||
|
(let
|
||||||
|
((yoe (- y (* era 400)))
|
||||||
|
(doy
|
||||||
|
(+
|
||||||
|
(ev-floor-div
|
||||||
|
(+
|
||||||
|
(*
|
||||||
|
153
|
||||||
|
(+ m (if (> m 2) -3 9)))
|
||||||
|
2)
|
||||||
|
5)
|
||||||
|
(- d 1))))
|
||||||
|
(let
|
||||||
|
((doe (+ (* yoe 365) (ev-floor-div yoe 4) (- (ev-floor-div yoe 100)) doy)))
|
||||||
|
(+ (* era 146097) doe -719468)))))))
|
||||||
|
|
||||||
|
;; Civil (y m d) list from a day-number.
|
||||||
|
(define
|
||||||
|
ev-civil-from-days
|
||||||
|
(fn
|
||||||
|
(z0)
|
||||||
|
(let
|
||||||
|
((z (+ z0 719468)))
|
||||||
|
(let
|
||||||
|
((era (ev-floor-div (if (>= z 0) z (- z 146096)) 146097)))
|
||||||
|
(let
|
||||||
|
((doe (- z (* era 146097))))
|
||||||
|
(let
|
||||||
|
((yoe (ev-floor-div (+ (- doe (ev-floor-div doe 1460)) (ev-floor-div doe 36524) (- (ev-floor-div doe 146096))) 365)))
|
||||||
|
(let
|
||||||
|
((y (+ yoe (* era 400)))
|
||||||
|
(doy
|
||||||
|
(-
|
||||||
|
doe
|
||||||
|
(+
|
||||||
|
(* 365 yoe)
|
||||||
|
(ev-floor-div yoe 4)
|
||||||
|
(- (ev-floor-div yoe 100))))))
|
||||||
|
(let
|
||||||
|
((mp (ev-floor-div (+ (* 5 doy) 2) 153)))
|
||||||
|
(let
|
||||||
|
((d (+ (- doy (ev-floor-div (+ (* 153 mp) 2) 5)) 1))
|
||||||
|
(m
|
||||||
|
(if
|
||||||
|
(< mp 10)
|
||||||
|
(+ mp 3)
|
||||||
|
(- mp 9))))
|
||||||
|
(list (if (<= m 2) (+ y 1) y) m d))))))))))
|
||||||
|
|
||||||
|
;; Weekday of a day-number: 0=Mon .. 6=Sun (1970-01-01 is Thursday = 3).
|
||||||
|
(define ev-weekday-of-days (fn (z) (modulo (+ z 3) 7)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-days-in-month
|
||||||
|
(fn
|
||||||
|
(y m)
|
||||||
|
(-
|
||||||
|
(ev-days-from-civil
|
||||||
|
(if (= m 12) (+ y 1) y)
|
||||||
|
(if (= m 12) 1 (+ m 1))
|
||||||
|
1)
|
||||||
|
(ev-days-from-civil y m 1))))
|
||||||
|
|
||||||
|
;; Add k months to (y,m), returning (list y2 m2).
|
||||||
|
(define
|
||||||
|
ev-add-months
|
||||||
|
(fn
|
||||||
|
(y m k)
|
||||||
|
(let
|
||||||
|
((total (+ (* y 12) (- m 1) k)))
|
||||||
|
(list
|
||||||
|
(ev-floor-div total 12)
|
||||||
|
(+ (modulo total 12) 1)))))
|
||||||
|
|
||||||
|
;; ---- datetime (epoch minutes) ----
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-dt
|
||||||
|
(fn
|
||||||
|
(y m d hh mm)
|
||||||
|
(+ (* (ev-days-from-civil y m d) 1440) (* hh 60) mm)))
|
||||||
|
|
||||||
|
(define ev-date (fn (y m d) (ev-dt y m d 0 0)))
|
||||||
|
|
||||||
|
(define ev-dt->days (fn (t) (ev-floor-div t 1440)))
|
||||||
|
|
||||||
|
(define ev-dt->civil (fn (t) (ev-civil-from-days (ev-dt->days t))))
|
||||||
|
|
||||||
|
(define ev-dt-weekday (fn (t) (ev-weekday-of-days (ev-dt->days t))))
|
||||||
|
|
||||||
|
(define ev-dt-tod (fn (t) (modulo t 1440)))
|
||||||
|
|
||||||
|
(define ev-civ-y (fn (c) (first c)))
|
||||||
|
(define ev-civ-m (fn (c) (first (rest c))))
|
||||||
|
(define ev-civ-d (fn (c) (first (rest (rest c)))))
|
||||||
|
|
||||||
|
;; ---- event + occurrence constructors ----
|
||||||
|
|
||||||
|
;; rrule is nil (single event) or a dict:
|
||||||
|
;; {:freq :daily|:weekly|:monthly :interval N :count N|nil :until DT|nil
|
||||||
|
;; :byday ...|nil :bymonthday (list 15 -1)|nil}
|
||||||
|
;; weekly :byday -> (list 0 2 4) weekday numbers, 0=Mon
|
||||||
|
;; monthly :byday -> (list {:ord 2 :wd 1}) nth weekday (ord<0 from end)
|
||||||
|
;; monthly :bymonthday -> (list 15 -1) day of month (negative from end)
|
||||||
|
(define ev-event (fn (id dtstart duration rrule capacity) {:duration duration :id id :dtstart dtstart :capacity capacity :rrule rrule}))
|
||||||
|
|
||||||
|
(define ev-occ (fn (id start dur) {:id id :start start :end (+ start dur)}))
|
||||||
|
|
||||||
|
;; ---- DAILY expansion ----
|
||||||
|
;; occ starts at dtstart; n counts every generated occurrence (window-
|
||||||
|
;; independent, so COUNT/UNTIL bound the rule, not the view). Emits only
|
||||||
|
;; occurrences inside [win-start, win-end].
|
||||||
|
(define
|
||||||
|
ev-daily-loop
|
||||||
|
(fn
|
||||||
|
(id occ duration step count until dtstart win-start win-end acc n)
|
||||||
|
(cond
|
||||||
|
((> occ win-end) acc)
|
||||||
|
((and (not (nil? count)) (>= n count)) acc)
|
||||||
|
((and (not (nil? until)) (> occ until)) acc)
|
||||||
|
(else
|
||||||
|
(begin
|
||||||
|
(when (>= occ win-start) (append! acc (ev-occ id occ duration)))
|
||||||
|
(ev-daily-loop
|
||||||
|
id
|
||||||
|
(+ occ step)
|
||||||
|
duration
|
||||||
|
step
|
||||||
|
count
|
||||||
|
until
|
||||||
|
dtstart
|
||||||
|
win-start
|
||||||
|
win-end
|
||||||
|
acc
|
||||||
|
(+ n 1)))))))
|
||||||
|
|
||||||
|
;; ---- shared per-period emit ----
|
||||||
|
;; Walk a start-ascending list of candidate occurrence datetimes for one
|
||||||
|
;; period, generating (count toward COUNT) those >= dtstart within UNTIL, and
|
||||||
|
;; emitting those also inside the window. Returns the updated running n.
|
||||||
|
(define
|
||||||
|
ev-emit-occs
|
||||||
|
(fn
|
||||||
|
(id occs duration count until dtstart win-start win-end acc n)
|
||||||
|
(if
|
||||||
|
(empty? occs)
|
||||||
|
n
|
||||||
|
(let
|
||||||
|
((occ (first occs)))
|
||||||
|
(let
|
||||||
|
((generates? (and (>= occ dtstart) (or (nil? until) (<= occ until)) (or (nil? count) (< n count)))))
|
||||||
|
(begin
|
||||||
|
(when
|
||||||
|
(and generates? (>= occ win-start) (<= occ win-end))
|
||||||
|
(append! acc (ev-occ id occ duration)))
|
||||||
|
(ev-emit-occs
|
||||||
|
id
|
||||||
|
(rest occs)
|
||||||
|
duration
|
||||||
|
count
|
||||||
|
until
|
||||||
|
dtstart
|
||||||
|
win-start
|
||||||
|
win-end
|
||||||
|
acc
|
||||||
|
(if generates? (+ n 1) n))))))))
|
||||||
|
|
||||||
|
;; ---- WEEKLY expansion ----
|
||||||
|
;; Iterate week by week from the Monday of dtstart's week; within each active
|
||||||
|
;; week emit each BYDAY (sorted). n counts every generated occurrence.
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-week0-days
|
||||||
|
(fn (dtstart) (- (ev-dt->days dtstart) (ev-dt-weekday dtstart))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-byday-default
|
||||||
|
(fn
|
||||||
|
(byday dtstart)
|
||||||
|
(if (nil? byday) (list (ev-dt-weekday dtstart)) (sort byday))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-weekly-loop
|
||||||
|
(fn
|
||||||
|
(id
|
||||||
|
week-days
|
||||||
|
tod
|
||||||
|
duration
|
||||||
|
week-step
|
||||||
|
bd
|
||||||
|
count
|
||||||
|
until
|
||||||
|
dtstart
|
||||||
|
win-start
|
||||||
|
win-end
|
||||||
|
acc
|
||||||
|
n)
|
||||||
|
(let
|
||||||
|
((week-start-dt (* week-days 1440)))
|
||||||
|
(cond
|
||||||
|
((> week-start-dt win-end) acc)
|
||||||
|
((and (not (nil? count)) (>= n count)) acc)
|
||||||
|
(else
|
||||||
|
(let
|
||||||
|
((occs (map (fn (wd) (+ (* (+ week-days wd) 1440) tod)) bd)))
|
||||||
|
(let
|
||||||
|
((n2 (ev-emit-occs id occs duration count until dtstart win-start win-end acc n)))
|
||||||
|
(ev-weekly-loop
|
||||||
|
id
|
||||||
|
(+ week-days week-step)
|
||||||
|
tod
|
||||||
|
duration
|
||||||
|
week-step
|
||||||
|
bd
|
||||||
|
count
|
||||||
|
until
|
||||||
|
dtstart
|
||||||
|
win-start
|
||||||
|
win-end
|
||||||
|
acc
|
||||||
|
n2))))))))
|
||||||
|
|
||||||
|
;; ---- MONTHLY expansion ----
|
||||||
|
;; Iterate month by month from dtstart's month, stepping by INTERVAL months.
|
||||||
|
;; Candidate days per month come from BYMONTHDAY, then ordinal BYDAY, else the
|
||||||
|
;; day-of-month of dtstart (skipped in months too short to contain it).
|
||||||
|
|
||||||
|
;; Resolve a BYMONTHDAY value to a valid day-of-month, or nil.
|
||||||
|
(define
|
||||||
|
ev-resolve-monthday
|
||||||
|
(fn
|
||||||
|
(y m bmd)
|
||||||
|
(let
|
||||||
|
((dim (ev-days-in-month y m)))
|
||||||
|
(let
|
||||||
|
((day (if (< bmd 0) (+ dim 1 bmd) bmd)))
|
||||||
|
(if (and (>= day 1) (<= day dim)) day nil)))))
|
||||||
|
|
||||||
|
;; Resolve an ordinal weekday {:ord :wd} to a day-of-month, or nil.
|
||||||
|
(define
|
||||||
|
ev-resolve-nth-weekday
|
||||||
|
(fn
|
||||||
|
(y m ord wd)
|
||||||
|
(let
|
||||||
|
((dim (ev-days-in-month y m)))
|
||||||
|
(if
|
||||||
|
(> ord 0)
|
||||||
|
(let
|
||||||
|
((first-wd (ev-weekday-of-days (ev-days-from-civil y m 1))))
|
||||||
|
(let
|
||||||
|
((day (+ 1 (modulo (- wd first-wd) 7) (* (- ord 1) 7))))
|
||||||
|
(if (<= day dim) day nil)))
|
||||||
|
(let
|
||||||
|
((last-wd (ev-weekday-of-days (ev-days-from-civil y m dim))))
|
||||||
|
(let
|
||||||
|
((day (- dim (modulo (- last-wd wd) 7) (* (- (- ord) 1) 7))))
|
||||||
|
(if (>= day 1) day nil)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-month-candidates
|
||||||
|
(fn
|
||||||
|
(y m rrule dtstart)
|
||||||
|
(let
|
||||||
|
((bmd (get rrule :bymonthday)) (byday (get rrule :byday)))
|
||||||
|
(cond
|
||||||
|
((not (nil? bmd))
|
||||||
|
(ev-filter-nil (map (fn (d) (ev-resolve-monthday y m d)) bmd)))
|
||||||
|
((not (nil? byday))
|
||||||
|
(ev-filter-nil
|
||||||
|
(map
|
||||||
|
(fn
|
||||||
|
(e)
|
||||||
|
(ev-resolve-nth-weekday y m (get e :ord) (get e :wd)))
|
||||||
|
byday)))
|
||||||
|
(else
|
||||||
|
(ev-filter-nil
|
||||||
|
(list
|
||||||
|
(ev-resolve-monthday y m (ev-civ-d (ev-dt->civil dtstart))))))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-monthly-loop
|
||||||
|
(fn
|
||||||
|
(id
|
||||||
|
y
|
||||||
|
m
|
||||||
|
rrule
|
||||||
|
duration
|
||||||
|
tod
|
||||||
|
interval
|
||||||
|
count
|
||||||
|
until
|
||||||
|
dtstart
|
||||||
|
win-start
|
||||||
|
win-end
|
||||||
|
acc
|
||||||
|
n)
|
||||||
|
(let
|
||||||
|
((month-start (ev-dt y m 1 0 0)))
|
||||||
|
(cond
|
||||||
|
((> month-start win-end) acc)
|
||||||
|
((and (not (nil? count)) (>= n count)) acc)
|
||||||
|
(else
|
||||||
|
(let
|
||||||
|
((days (sort (ev-month-candidates y m rrule dtstart))))
|
||||||
|
(let
|
||||||
|
((occs (map (fn (d) (+ (* (ev-days-from-civil y m d) 1440) tod)) days)))
|
||||||
|
(let
|
||||||
|
((n2 (ev-emit-occs id occs duration count until dtstart win-start win-end acc n))
|
||||||
|
(nm (ev-add-months y m interval)))
|
||||||
|
(ev-monthly-loop
|
||||||
|
id
|
||||||
|
(ev-civ-y nm)
|
||||||
|
(ev-civ-m nm)
|
||||||
|
rrule
|
||||||
|
duration
|
||||||
|
tod
|
||||||
|
interval
|
||||||
|
count
|
||||||
|
until
|
||||||
|
dtstart
|
||||||
|
win-start
|
||||||
|
win-end
|
||||||
|
acc
|
||||||
|
n2)))))))))
|
||||||
|
|
||||||
|
;; ---- top-level expansion ----
|
||||||
|
;; Returns a list of occurrence dicts {:id :start :end} within the window.
|
||||||
|
(define
|
||||||
|
ev-expand
|
||||||
|
(fn
|
||||||
|
(event win-start win-end)
|
||||||
|
(let
|
||||||
|
((id (get event :id))
|
||||||
|
(dtstart (get event :dtstart))
|
||||||
|
(duration (get event :duration))
|
||||||
|
(rrule (get event :rrule)))
|
||||||
|
(if
|
||||||
|
(nil? rrule)
|
||||||
|
(if
|
||||||
|
(and (>= dtstart win-start) (<= dtstart win-end))
|
||||||
|
(list (ev-occ id dtstart duration))
|
||||||
|
(list))
|
||||||
|
(let
|
||||||
|
((freq (get rrule :freq))
|
||||||
|
(interval (ev-or (get rrule :interval) 1))
|
||||||
|
(count (get rrule :count))
|
||||||
|
(until (get rrule :until))
|
||||||
|
(byday (get rrule :byday))
|
||||||
|
(acc (list)))
|
||||||
|
(begin
|
||||||
|
(cond
|
||||||
|
((= freq :daily)
|
||||||
|
(ev-daily-loop
|
||||||
|
id
|
||||||
|
dtstart
|
||||||
|
duration
|
||||||
|
(* interval 1440)
|
||||||
|
count
|
||||||
|
until
|
||||||
|
dtstart
|
||||||
|
win-start
|
||||||
|
win-end
|
||||||
|
acc
|
||||||
|
0))
|
||||||
|
((= freq :weekly)
|
||||||
|
(ev-weekly-loop
|
||||||
|
id
|
||||||
|
(ev-week0-days dtstart)
|
||||||
|
(ev-dt-tod dtstart)
|
||||||
|
duration
|
||||||
|
(* interval 7)
|
||||||
|
(ev-byday-default byday dtstart)
|
||||||
|
count
|
||||||
|
until
|
||||||
|
dtstart
|
||||||
|
win-start
|
||||||
|
win-end
|
||||||
|
acc
|
||||||
|
0))
|
||||||
|
((= freq :monthly)
|
||||||
|
(let
|
||||||
|
((civ (ev-dt->civil dtstart)))
|
||||||
|
(ev-monthly-loop
|
||||||
|
id
|
||||||
|
(ev-civ-y civ)
|
||||||
|
(ev-civ-m civ)
|
||||||
|
rrule
|
||||||
|
duration
|
||||||
|
(ev-dt-tod dtstart)
|
||||||
|
interval
|
||||||
|
count
|
||||||
|
until
|
||||||
|
dtstart
|
||||||
|
win-start
|
||||||
|
win-end
|
||||||
|
acc
|
||||||
|
0)))
|
||||||
|
(else (error (str "ev-expand: unsupported freq: " freq))))
|
||||||
|
acc))))))
|
||||||
|
|
||||||
|
;; ---- multi-event expansion (sorted by start) ----
|
||||||
|
|
||||||
|
;; Insertion of one occurrence into a start-ascending list.
|
||||||
|
(define
|
||||||
|
ev-occ-insert
|
||||||
|
(fn
|
||||||
|
(o sorted)
|
||||||
|
(cond
|
||||||
|
((empty? sorted) (list o))
|
||||||
|
((<= (get o :start) (get (first sorted) :start)) (cons o sorted))
|
||||||
|
(else (cons (first sorted) (ev-occ-insert o (rest sorted)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-sort-occs
|
||||||
|
(fn (occs) (reduce (fn (acc o) (ev-occ-insert o acc)) (list) occs)))
|
||||||
|
|
||||||
|
;; Expand many events into one occurrence list, ascending by start.
|
||||||
|
(define
|
||||||
|
ev-expand-all
|
||||||
|
(fn
|
||||||
|
(events win-start win-end)
|
||||||
|
(let
|
||||||
|
((acc (list)))
|
||||||
|
(begin
|
||||||
|
(for-each
|
||||||
|
(fn
|
||||||
|
(ev)
|
||||||
|
(for-each
|
||||||
|
(fn (o) (append! acc o))
|
||||||
|
(ev-expand ev win-start win-end)))
|
||||||
|
events)
|
||||||
|
(ev-sort-occs acc)))))
|
||||||
56
lib/events/conformance.conf
Normal file
56
lib/events/conformance.conf
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# events-on-sx conformance config — sourced by lib/guest/conformance.sh.
|
||||||
|
|
||||||
|
LANG_NAME=events
|
||||||
|
MODE=dict
|
||||||
|
SCOREBOARD_DIR=lib/events
|
||||||
|
|
||||||
|
PRELOADS=(
|
||||||
|
spec/stdlib.sx
|
||||||
|
lib/r7rs.sx
|
||||||
|
lib/datalog/tokenizer.sx
|
||||||
|
lib/datalog/parser.sx
|
||||||
|
lib/datalog/unify.sx
|
||||||
|
lib/datalog/db.sx
|
||||||
|
lib/datalog/builtins.sx
|
||||||
|
lib/datalog/aggregates.sx
|
||||||
|
lib/datalog/strata.sx
|
||||||
|
lib/datalog/eval.sx
|
||||||
|
lib/datalog/api.sx
|
||||||
|
lib/datalog/magic.sx
|
||||||
|
lib/events/calendar.sx
|
||||||
|
lib/events/availability.sx
|
||||||
|
lib/persist/event.sx
|
||||||
|
lib/persist/backend.sx
|
||||||
|
lib/persist/log.sx
|
||||||
|
lib/persist/kv.sx
|
||||||
|
lib/persist/concurrency.sx
|
||||||
|
lib/persist/api.sx
|
||||||
|
lib/events/booking.sx
|
||||||
|
lib/events/ticket.sx
|
||||||
|
lib/guest/lex.sx
|
||||||
|
lib/guest/reflective/env.sx
|
||||||
|
lib/guest/reflective/quoting.sx
|
||||||
|
lib/scheme/parser.sx
|
||||||
|
lib/scheme/eval.sx
|
||||||
|
lib/scheme/runtime.sx
|
||||||
|
lib/flow/spec.sx
|
||||||
|
lib/flow/store.sx
|
||||||
|
lib/flow/remote.sx
|
||||||
|
lib/flow/host.sx
|
||||||
|
lib/flow/api.sx
|
||||||
|
lib/events/notify.sx
|
||||||
|
lib/events/api.sx
|
||||||
|
lib/events/reminders.sx
|
||||||
|
lib/events/federation.sx
|
||||||
|
)
|
||||||
|
|
||||||
|
SUITES=(
|
||||||
|
"calendar:lib/events/tests/calendar.sx:(ev-calendar-tests-run!)"
|
||||||
|
"availability:lib/events/tests/availability.sx:(ev-availability-tests-run!)"
|
||||||
|
"api:lib/events/tests/api.sx:(ev-api-tests-run!)"
|
||||||
|
"booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)"
|
||||||
|
"ticket:lib/events/tests/ticket.sx:(ev-ticket-tests-run!)"
|
||||||
|
"notify:lib/events/tests/notify.sx:(ev-notify-tests-run!)"
|
||||||
|
"reminders:lib/events/tests/reminders.sx:(ev-reminders-tests-run!)"
|
||||||
|
"federation:lib/events/tests/federation.sx:(ev-federation-tests-run!)"
|
||||||
|
)
|
||||||
3
lib/events/conformance.sh
Executable file
3
lib/events/conformance.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Thin wrapper — see lib/guest/conformance.sh and lib/events/conformance.conf.
|
||||||
|
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||||
165
lib/events/federation.sx
Normal file
165
lib/events/federation.sx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
;; lib/events/federation.sx — cross-instance calendar federation (trust-gated).
|
||||||
|
;;
|
||||||
|
;; A peer is another events instance that publishes a schedule (an events
|
||||||
|
;; store). We merge a peer's agenda into ours ONLY if we trust it — trust is a
|
||||||
|
;; set of peer ids, re-checked on every merge, so revoking a peer takes effect
|
||||||
|
;; immediately. Merged occurrences carry :origin provenance (:local for ours, or
|
||||||
|
;; the peer id) so a consumer always knows where a slot came from.
|
||||||
|
;;
|
||||||
|
;; This is the trust-gated stub: peers publish plain schedules and we fold the
|
||||||
|
;; trusted ones into a single sorted agenda. Real transport (fed-sx / signed
|
||||||
|
;; fetch) slots in behind `ev/peer-agenda` without changing the merge.
|
||||||
|
;;
|
||||||
|
;; Federated FREE/BUSY follows the iCal model: a peer publishes BUSY intervals
|
||||||
|
;; for an actor (not event details — privacy-preserving), and we union local +
|
||||||
|
;; trusted-peer busy to answer "is this actor free?" across instances.
|
||||||
|
|
||||||
|
(define ev/peer (fn (id store) {:id id :busy (list) :store store}))
|
||||||
|
|
||||||
|
;; A peer that also publishes free/busy: `busy` is a list of
|
||||||
|
;; (actor ((start end) ...)) pairs.
|
||||||
|
(define ev/peer-with-busy (fn (id store busy) {:id id :busy busy :store store}))
|
||||||
|
|
||||||
|
(define ev/peer-id (fn (p) (get p :id)))
|
||||||
|
(define ev/peer-store (fn (p) (get p :store)))
|
||||||
|
(define ev/peer-busy-table (fn (p) (get p :busy)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-fed-member?
|
||||||
|
(fn
|
||||||
|
(x xs)
|
||||||
|
(cond
|
||||||
|
((empty? xs) false)
|
||||||
|
((= x (first xs)) true)
|
||||||
|
(else (ev-fed-member? x (rest xs))))))
|
||||||
|
|
||||||
|
;; Do we trust this peer id? (trust is a list of trusted peer ids.)
|
||||||
|
(define ev/trusts? (fn (trust peer-id) (ev-fed-member? peer-id trust)))
|
||||||
|
|
||||||
|
;; The trusted subset of a peer list.
|
||||||
|
(define
|
||||||
|
ev/trusted-peers
|
||||||
|
(fn
|
||||||
|
(peers trust)
|
||||||
|
(filter (fn (p) (ev/trusts? trust (ev/peer-id p))) peers)))
|
||||||
|
|
||||||
|
;; Tag occurrences with provenance.
|
||||||
|
(define ev-tag-origin (fn (occs origin) (map (fn (o) {:id (get o :id) :start (get o :start) :end (get o :end) :origin origin}) occs)))
|
||||||
|
|
||||||
|
;; A peer's agenda over [ws, we), tagged with the peer's id as :origin.
|
||||||
|
(define
|
||||||
|
ev/peer-agenda
|
||||||
|
(fn
|
||||||
|
(peer ws we)
|
||||||
|
(ev-tag-origin (ev/agenda (ev/peer-store peer) ws we) (ev/peer-id peer))))
|
||||||
|
|
||||||
|
;; ---- merge (sorted by start, then origin for ties) ----
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-fed-before?
|
||||||
|
(fn
|
||||||
|
(a c)
|
||||||
|
(cond
|
||||||
|
((< (get a :start) (get c :start)) true)
|
||||||
|
((> (get a :start) (get c :start)) false)
|
||||||
|
(else (< (str (get a :origin)) (str (get c :origin)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-fed-insert
|
||||||
|
(fn
|
||||||
|
(x sorted)
|
||||||
|
(cond
|
||||||
|
((empty? sorted) (list x))
|
||||||
|
((ev-fed-before? x (first sorted)) (cons x sorted))
|
||||||
|
(else (cons (first sorted) (ev-fed-insert x (rest sorted)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-fed-sort
|
||||||
|
(fn (xs) (reduce (fn (acc x) (ev-fed-insert x acc)) (list) xs)))
|
||||||
|
|
||||||
|
;; Local agenda (origin :local) merged with every TRUSTED peer's agenda,
|
||||||
|
;; sorted by start. Untrusted peers contribute nothing.
|
||||||
|
(define
|
||||||
|
ev/federated-agenda
|
||||||
|
(fn
|
||||||
|
(local-store peers trust ws we)
|
||||||
|
(let
|
||||||
|
((acc (list)))
|
||||||
|
(begin
|
||||||
|
(for-each
|
||||||
|
(fn (o) (append! acc o))
|
||||||
|
(ev-tag-origin (ev/agenda local-store ws we) :local))
|
||||||
|
(for-each
|
||||||
|
(fn
|
||||||
|
(peer)
|
||||||
|
(when
|
||||||
|
(ev/trusts? trust (ev/peer-id peer))
|
||||||
|
(for-each
|
||||||
|
(fn (o) (append! acc o))
|
||||||
|
(ev/peer-agenda peer ws we))))
|
||||||
|
peers)
|
||||||
|
(ev-fed-sort acc)))))
|
||||||
|
|
||||||
|
;; Filter a federated agenda to occurrences from one origin.
|
||||||
|
(define
|
||||||
|
ev/from-origin
|
||||||
|
(fn
|
||||||
|
(agenda origin)
|
||||||
|
(filter (fn (o) (= (get o :origin) origin)) agenda)))
|
||||||
|
|
||||||
|
;; ---- federated free/busy ----
|
||||||
|
|
||||||
|
;; A peer's published busy intervals for `actor` ((start end) ...), or empty.
|
||||||
|
(define
|
||||||
|
ev/peer-busy
|
||||||
|
(fn
|
||||||
|
(peer actor)
|
||||||
|
(let
|
||||||
|
((row (ev-fed-assoc actor (ev/peer-busy-table peer))))
|
||||||
|
(if (nil? row) (list) (first (rest row))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-fed-assoc
|
||||||
|
(fn
|
||||||
|
(k pairs)
|
||||||
|
(cond
|
||||||
|
((empty? pairs) nil)
|
||||||
|
((= (first (first pairs)) k) (first pairs))
|
||||||
|
(else (ev-fed-assoc k (rest pairs))))))
|
||||||
|
|
||||||
|
;; All busy intervals for `actor` across the LOCAL availability db plus every
|
||||||
|
;; TRUSTED peer's published free/busy, merged and sorted by start.
|
||||||
|
;; `local-db` is an availability db (see availability.sx ev-build-avail).
|
||||||
|
(define
|
||||||
|
ev/federated-busy
|
||||||
|
(fn
|
||||||
|
(local-db peers trust actor)
|
||||||
|
(let
|
||||||
|
((acc (list)))
|
||||||
|
(begin
|
||||||
|
(for-each (fn (iv) (append! acc iv)) (ev-busy local-db actor))
|
||||||
|
(for-each
|
||||||
|
(fn
|
||||||
|
(peer)
|
||||||
|
(when
|
||||||
|
(ev/trusts? trust (ev/peer-id peer))
|
||||||
|
(for-each
|
||||||
|
(fn (iv) (append! acc iv))
|
||||||
|
(ev/peer-busy peer actor))))
|
||||||
|
peers)
|
||||||
|
(ev-sort-lists acc)))))
|
||||||
|
|
||||||
|
;; Half-open overlap of interval (s e) with window [qs, qe).
|
||||||
|
(define
|
||||||
|
ev-fed-overlaps?
|
||||||
|
(fn (iv qs qe) (and (< (first iv) qe) (< qs (first (rest iv))))))
|
||||||
|
|
||||||
|
;; Is `actor` free across [qs, qe) considering local + trusted-peer busy?
|
||||||
|
(define
|
||||||
|
ev/federated-free?
|
||||||
|
(fn
|
||||||
|
(local-db peers trust actor qs qe)
|
||||||
|
(not
|
||||||
|
(some
|
||||||
|
(fn (iv) (ev-fed-overlaps? iv qs qe))
|
||||||
|
(ev/federated-busy local-db peers trust actor)))))
|
||||||
38
lib/events/notify.sx
Normal file
38
lib/events/notify.sx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
;; lib/events/notify.sx — durable notification delivery flows over an injected
|
||||||
|
;; transport (lib/flow).
|
||||||
|
;;
|
||||||
|
;; Reminders and digests are durable `flow`s: a flow `request`s delivery (a
|
||||||
|
;; suspend point), the HOST performs the actual send via an injected `dispatch`
|
||||||
|
;; (the transport — email/push/etc.), and resumes the flow with the outcome.
|
||||||
|
;; Because flow uses deterministic replay, a completed delivery is never re-run
|
||||||
|
;; on recovery; the host owns IO and persistence.
|
||||||
|
;;
|
||||||
|
;; Delivery is AT-LEAST-ONCE with idempotency. Each message carries an id (the
|
||||||
|
;; idempotency key). Two protections stop double-delivery:
|
||||||
|
;; 1. The transport dedups by id — a re-send of a delivered id is a no-op
|
||||||
|
;; that still reports ok, so a retry never produces two pings.
|
||||||
|
;; 2. flow's replay log records each resolved request, so recovery replays the
|
||||||
|
;; logged outcome instead of re-issuing the send.
|
||||||
|
;;
|
||||||
|
;; Retry/backoff rides flow suspend/resume: each attempt issues a request with a
|
||||||
|
;; DISTINCT tag `(deliver <id> <n>)` — distinct tags keep deterministic replay
|
||||||
|
;; correct across retries. The dispatch returns (ok info) to finish or
|
||||||
|
;; (retry reason) to try again, bounded by `maxn` (then (failed id reason)).
|
||||||
|
;;
|
||||||
|
;; A message is a 3-element list (id recipient body). The transport is generic
|
||||||
|
;; and injected — when feed/notify lands, both consumers share one transport,
|
||||||
|
;; so this delivery core is a candidate for extraction to `delivery-on-sx`.
|
||||||
|
;;
|
||||||
|
;; The Scheme flow source below loads into a flow env (see lib/flow/api.sx).
|
||||||
|
;; `ev/notify-run` prepends it to a caller program and evaluates in the shared
|
||||||
|
;; flow env.
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-notify-flows-src
|
||||||
|
"(define (ev-msg-id m) (car m))\n (define (ev-msg-recipient m) (car (cdr m)))\n (define (ev-msg-body m) (car (cdr (cdr m))))\n (define (ev-mem x xs)\n (if (null? xs) #f (if (equal? x (car xs)) #t (ev-mem x (cdr xs)))))\n (define (ev-notify-attempt m n maxn)\n (let ((r (request (list (quote deliver) (ev-msg-id m) n) m)))\n (if (eq? (car r) (quote ok))\n (list (quote delivered) (ev-msg-id m) n)\n (if (>= n maxn)\n (list (quote failed) (ev-msg-id m) (car (cdr r)))\n (ev-notify-attempt m (+ n 1) maxn)))))\n (define (ev-deliver-reminder maxn)\n (flow-node (lambda (m) (ev-notify-attempt m 1 maxn))))\n (define (ev-digest-step ms maxn)\n (if (null? ms)\n (list)\n (cons (ev-notify-attempt (car ms) 1 maxn)\n (ev-digest-step (cdr ms) maxn))))\n (define (ev-deliver-digest maxn)\n (flow-node (lambda (ms) (ev-digest-step ms maxn))))")
|
||||||
|
|
||||||
|
;; Run a Scheme flow program with the notify flows preloaded, in the shared
|
||||||
|
;; flow env. Returns the program's value (SX-native).
|
||||||
|
(define
|
||||||
|
ev/notify-run
|
||||||
|
(fn (prog) (flow-run (str ev-notify-flows-src "\n" prog))))
|
||||||
96
lib/events/reminders.sx
Normal file
96
lib/events/reminders.sx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
;; lib/events/reminders.sx — derive reminder + digest messages from the agenda.
|
||||||
|
;;
|
||||||
|
;; Bridges the schedule (calendar) and the durable roster (booking on persist)
|
||||||
|
;; to the notification layer (notify.sx). For each booked attendee of each
|
||||||
|
;; upcoming occurrence we derive a reminder message that fires `lead` minutes
|
||||||
|
;; before the occurrence starts. Each message has a deterministic idempotency
|
||||||
|
;; key — occ-key / recipient / lead — so re-deriving over an overlapping window
|
||||||
|
;; never produces a duplicate ping (the notify transport dedups on this id).
|
||||||
|
;;
|
||||||
|
;; A reminder is a dict:
|
||||||
|
;; {:id :recipient :event :start :fire-at}
|
||||||
|
;; `ev/reminder->msg` projects it to notify's (id recipient body) wire shape.
|
||||||
|
|
||||||
|
;; Reminders for one occurrence: one per booked attendee (durable roster).
|
||||||
|
(define
|
||||||
|
ev/occurrence-reminders
|
||||||
|
(fn
|
||||||
|
(b occ lead)
|
||||||
|
(let
|
||||||
|
((occ-key (ev-occ-key occ))
|
||||||
|
(start (get occ :start))
|
||||||
|
(evid (get occ :id)))
|
||||||
|
(map (fn (actor) {:id (str occ-key "/" actor "/" lead) :event evid :start start :fire-at (- start lead) :recipient actor}) (ev/roster-occ b occ)))))
|
||||||
|
|
||||||
|
;; Insertion sort of reminder dicts ascending by :fire-at (then :id for ties).
|
||||||
|
(define
|
||||||
|
ev-rem-before?
|
||||||
|
(fn
|
||||||
|
(a c)
|
||||||
|
(cond
|
||||||
|
((< (get a :fire-at) (get c :fire-at)) true)
|
||||||
|
((> (get a :fire-at) (get c :fire-at)) false)
|
||||||
|
(else (< (get a :id) (get c :id))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-rem-insert
|
||||||
|
(fn
|
||||||
|
(r sorted)
|
||||||
|
(cond
|
||||||
|
((empty? sorted) (list r))
|
||||||
|
((ev-rem-before? r (first sorted)) (cons r sorted))
|
||||||
|
(else (cons (first sorted) (ev-rem-insert r (rest sorted)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-rem-sort
|
||||||
|
(fn (rs) (reduce (fn (acc r) (ev-rem-insert r acc)) (list) rs)))
|
||||||
|
|
||||||
|
;; All reminders across the agenda in [ws, we), ascending by fire-at.
|
||||||
|
(define
|
||||||
|
ev/agenda-reminders
|
||||||
|
(fn
|
||||||
|
(b store ws we lead)
|
||||||
|
(let
|
||||||
|
((acc (list)))
|
||||||
|
(begin
|
||||||
|
(for-each
|
||||||
|
(fn
|
||||||
|
(occ)
|
||||||
|
(for-each
|
||||||
|
(fn (r) (append! acc r))
|
||||||
|
(ev/occurrence-reminders b occ lead)))
|
||||||
|
(ev/agenda store ws we))
|
||||||
|
(ev-rem-sort acc)))))
|
||||||
|
|
||||||
|
;; Reminders whose fire-at has arrived (fire-at <= now) — what a scheduler
|
||||||
|
;; should hand to the notify transport at time `now`.
|
||||||
|
(define
|
||||||
|
ev/due-reminders
|
||||||
|
(fn
|
||||||
|
(reminders now)
|
||||||
|
(filter (fn (r) (<= (get r :fire-at) now)) reminders)))
|
||||||
|
|
||||||
|
;; Project a reminder to notify's (id recipient body) wire shape.
|
||||||
|
(define
|
||||||
|
ev/reminder->msg
|
||||||
|
(fn
|
||||||
|
(r)
|
||||||
|
(list
|
||||||
|
(get r :id)
|
||||||
|
(get r :recipient)
|
||||||
|
(list :reminder (get r :event) (get r :start)))))
|
||||||
|
|
||||||
|
;; ---- digests ----
|
||||||
|
|
||||||
|
;; The occurrences `actor` is booked into (durable roster), within window.
|
||||||
|
(define
|
||||||
|
ev/agenda-for-p
|
||||||
|
(fn
|
||||||
|
(b store actor ws we)
|
||||||
|
(filter
|
||||||
|
(fn (occ) (ev-bk-member? actor (ev/roster-occ b occ)))
|
||||||
|
(ev/agenda store ws we))))
|
||||||
|
|
||||||
|
;; A single digest message summarising an actor's upcoming booked occurrences.
|
||||||
|
;; :items is ({:event :start} ...); empty when the actor has nothing booked.
|
||||||
|
(define ev/agenda-digest (fn (b store actor ws we) {:items (map (fn (occ) {:event (get occ :id) :start (get occ :start)}) (ev/agenda-for-p b store actor ws we)) :id (str actor "/digest/" ws "-" we) :recipient actor}))
|
||||||
17
lib/events/scoreboard.json
Normal file
17
lib/events/scoreboard.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"lang": "events",
|
||||||
|
"total_passed": 219,
|
||||||
|
"total_failed": 0,
|
||||||
|
"total": 219,
|
||||||
|
"suites": [
|
||||||
|
{"name":"calendar","passed":37,"failed":0,"total":37},
|
||||||
|
{"name":"availability","passed":22,"failed":0,"total":22},
|
||||||
|
{"name":"api","passed":24,"failed":0,"total":24},
|
||||||
|
{"name":"booking","passed":61,"failed":0,"total":61},
|
||||||
|
{"name":"ticket","passed":31,"failed":0,"total":31},
|
||||||
|
{"name":"notify","passed":7,"failed":0,"total":7},
|
||||||
|
{"name":"reminders","passed":14,"failed":0,"total":14},
|
||||||
|
{"name":"federation","passed":23,"failed":0,"total":23}
|
||||||
|
],
|
||||||
|
"generated": "2026-06-07T05:31:56+00:00"
|
||||||
|
}
|
||||||
14
lib/events/scoreboard.md
Normal file
14
lib/events/scoreboard.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# events scoreboard
|
||||||
|
|
||||||
|
**219 / 219 passing** (0 failure(s)).
|
||||||
|
|
||||||
|
| Suite | Passed | Total | Status |
|
||||||
|
|-------|--------|-------|--------|
|
||||||
|
| calendar | 37 | 37 | ok |
|
||||||
|
| availability | 22 | 22 | ok |
|
||||||
|
| api | 24 | 24 | ok |
|
||||||
|
| booking | 61 | 61 | ok |
|
||||||
|
| ticket | 31 | 31 | ok |
|
||||||
|
| notify | 7 | 7 | ok |
|
||||||
|
| reminders | 14 | 14 | ok |
|
||||||
|
| federation | 23 | 23 | ok |
|
||||||
271
lib/events/tests/api.sx
Normal file
271
lib/events/tests/api.sx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
;; lib/events/tests/api.sx — public events facade (schedule/agenda/free/book).
|
||||||
|
|
||||||
|
(define ev-api-pass 0)
|
||||||
|
(define ev-api-fail 0)
|
||||||
|
(define ev-api-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-api-check!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! ev-api-pass (+ ev-api-pass 1))
|
||||||
|
(do
|
||||||
|
(set! ev-api-fail (+ ev-api-fail 1))
|
||||||
|
(append!
|
||||||
|
ev-api-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got))))))
|
||||||
|
|
||||||
|
;; A store with a weekly yoga class (Mon+Wed 18:00, 60m, 4 occurrences).
|
||||||
|
(define
|
||||||
|
ev-api-store
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(ev/schedule
|
||||||
|
(ev/empty)
|
||||||
|
(quote yoga)
|
||||||
|
(ev-dt 2026 6 1 18 0)
|
||||||
|
60
|
||||||
|
{:freq :weekly :count 4 :byday (list 0 2)}
|
||||||
|
20)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-api-run-all!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(let
|
||||||
|
((s0 (ev-api-store)))
|
||||||
|
(let
|
||||||
|
((occs (ev/agenda s0 (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||||
|
(let
|
||||||
|
((s1 (ev/book (ev/book s0 (quote nia) (ev-occ-key (first occs))) (quote nia) (ev-occ-key (first (rest occs))))))
|
||||||
|
(do
|
||||||
|
(ev-api-check!
|
||||||
|
"agenda expands weekly class to four occurrences"
|
||||||
|
(map (fn (o) (ev-dt->civil (get o :start))) occs)
|
||||||
|
(list
|
||||||
|
(list 2026 6 1)
|
||||||
|
(list 2026 6 3)
|
||||||
|
(list 2026 6 8)
|
||||||
|
(list 2026 6 10)))
|
||||||
|
(ev-api-check!
|
||||||
|
"empty store has empty agenda"
|
||||||
|
(ev/agenda
|
||||||
|
(ev/empty)
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1))
|
||||||
|
(list))
|
||||||
|
(ev-api-check!
|
||||||
|
"max duration reflects scheduled events"
|
||||||
|
(ev/store-max-duration s0)
|
||||||
|
60)
|
||||||
|
(ev-api-check!
|
||||||
|
"max duration of empty store is zero"
|
||||||
|
(ev/store-max-duration (ev/empty))
|
||||||
|
0)
|
||||||
|
(ev-api-check!
|
||||||
|
"event-by-id finds the scheduled event"
|
||||||
|
(get (ev/event-by-id s0 (quote yoga)) :capacity)
|
||||||
|
20)
|
||||||
|
(ev-api-check!
|
||||||
|
"event-by-id is nil for unknown id"
|
||||||
|
(ev/event-by-id s0 (quote nope))
|
||||||
|
nil)
|
||||||
|
(ev-api-check!
|
||||||
|
"agenda-for lists only booked occurrences"
|
||||||
|
(map
|
||||||
|
(fn (o) (ev-dt->civil (get o :start)))
|
||||||
|
(ev/agenda-for
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1)))
|
||||||
|
(list
|
||||||
|
(list 2026 6 1)
|
||||||
|
(list 2026 6 3)))
|
||||||
|
(ev-api-check!
|
||||||
|
"agenda-for empty for unbooked actor"
|
||||||
|
(ev/agenda-for
|
||||||
|
s1
|
||||||
|
(quote zed)
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1))
|
||||||
|
(list))
|
||||||
|
(ev-api-check!
|
||||||
|
"free? false during a booked occurrence"
|
||||||
|
(ev/free?
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-dt 2026 6 1 18 30)
|
||||||
|
(ev-dt 2026 6 1 19 0))
|
||||||
|
false)
|
||||||
|
(ev-api-check!
|
||||||
|
"free? true in an open window"
|
||||||
|
(ev/free?
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
(ev-dt 2026 6 1 10 0))
|
||||||
|
true)
|
||||||
|
(ev-api-check!
|
||||||
|
"free? half-open at occurrence end"
|
||||||
|
(ev/free?
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-dt 2026 6 1 19 0)
|
||||||
|
(ev-dt 2026 6 1 20 0))
|
||||||
|
true)
|
||||||
|
(ev-api-check!
|
||||||
|
"free? true for an actor who booked nothing"
|
||||||
|
(ev/free?
|
||||||
|
s1
|
||||||
|
(quote zed)
|
||||||
|
(ev-dt 2026 6 1 18 0)
|
||||||
|
(ev-dt 2026 6 1 19 0))
|
||||||
|
true)
|
||||||
|
(ev-api-check!
|
||||||
|
"next-free skips the booked slot to the hour after"
|
||||||
|
(ev-dt-tod
|
||||||
|
(ev/next-free
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
18
|
||||||
|
0)
|
||||||
|
60
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
23
|
||||||
|
0)))
|
||||||
|
(* 19 60))
|
||||||
|
(ev-api-check!
|
||||||
|
"next-free returns `after` when already open"
|
||||||
|
(ev/next-free
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
60
|
||||||
|
(ev-dt 2026 6 1 18 0))
|
||||||
|
(ev-dt 2026 6 1 9 0))
|
||||||
|
(ev-api-check!
|
||||||
|
"no conflict among disjoint bookings"
|
||||||
|
(ev/has-conflict?
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1))
|
||||||
|
false)
|
||||||
|
(let
|
||||||
|
((sc (ev/book (ev/schedule s1 (quote talk) (ev-dt 2026 6 1 18 30) 60 nil 5) (quote nia) (ev-occ-key (ev-occ (quote talk) (ev-dt 2026 6 1 18 30) 60)))))
|
||||||
|
(ev-api-check!
|
||||||
|
"overlapping second booking creates a conflict"
|
||||||
|
(ev/has-conflict?
|
||||||
|
sc
|
||||||
|
(quote nia)
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1))
|
||||||
|
true))
|
||||||
|
(let
|
||||||
|
((b (persist/open)) (occ1 (first occs)))
|
||||||
|
(do
|
||||||
|
(let
|
||||||
|
((sp (ev/schedule (ev/empty) (quote clinic) (ev-dt 2026 6 5 9 0) 30 nil 2)))
|
||||||
|
(let
|
||||||
|
((occ (ev-occ (quote clinic) (ev-dt 2026 6 5 9 0) 30)))
|
||||||
|
(do
|
||||||
|
(ev-api-check!
|
||||||
|
"durable book returns booked"
|
||||||
|
(get (ev/book-occ! b sp (quote a) occ) :status)
|
||||||
|
:booked)
|
||||||
|
(ev/book-occ! b sp (quote c) occ)
|
||||||
|
(ev-api-check!
|
||||||
|
"durable book past capacity is full"
|
||||||
|
(get (ev/book-occ! b sp (quote d) occ) :status)
|
||||||
|
:full)
|
||||||
|
(ev-api-check!
|
||||||
|
"durable roster reflects persisted bookings"
|
||||||
|
(ev/roster-occ b occ)
|
||||||
|
(list (quote a) (quote c)))
|
||||||
|
(ev-api-check!
|
||||||
|
"durable seats-left honours capacity"
|
||||||
|
(ev/seats-left-occ b sp occ)
|
||||||
|
0)
|
||||||
|
(ev-api-check!
|
||||||
|
"persist free? false during a durable booking"
|
||||||
|
(ev/free-p?
|
||||||
|
b
|
||||||
|
sp
|
||||||
|
(quote a)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
5
|
||||||
|
9
|
||||||
|
10)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
5
|
||||||
|
9
|
||||||
|
20))
|
||||||
|
false)
|
||||||
|
(ev-api-check!
|
||||||
|
"persist free? true in an open window"
|
||||||
|
(ev/free-p?
|
||||||
|
b
|
||||||
|
sp
|
||||||
|
(quote a)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
5
|
||||||
|
10
|
||||||
|
0)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
5
|
||||||
|
10
|
||||||
|
30))
|
||||||
|
true)
|
||||||
|
(ev/cancel-occ! b sp (quote a) occ)
|
||||||
|
(ev-api-check!
|
||||||
|
"durable cancel frees a seat"
|
||||||
|
(ev/seats-left-occ b sp occ)
|
||||||
|
1)
|
||||||
|
(ev-api-check!
|
||||||
|
"persist free? true after cancellation"
|
||||||
|
(ev/free-p?
|
||||||
|
b
|
||||||
|
sp
|
||||||
|
(quote a)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
5
|
||||||
|
9
|
||||||
|
10)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
5
|
||||||
|
9
|
||||||
|
20))
|
||||||
|
true))))))))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-api-tests-run!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(set! ev-api-pass 0)
|
||||||
|
(set! ev-api-fail 0)
|
||||||
|
(set! ev-api-failures (list))
|
||||||
|
(ev-api-run-all!)
|
||||||
|
{:failures ev-api-failures :total (+ ev-api-pass ev-api-fail) :passed ev-api-pass :failed ev-api-fail})))
|
||||||
331
lib/events/tests/availability.sx
Normal file
331
lib/events/tests/availability.sx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
;; lib/events/tests/availability.sx — free/busy + conflict rules on Datalog.
|
||||||
|
|
||||||
|
(define ev-av-pass 0)
|
||||||
|
(define ev-av-fail 0)
|
||||||
|
(define ev-av-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-av-check!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! ev-av-pass (+ ev-av-pass 1))
|
||||||
|
(do
|
||||||
|
(set! ev-av-fail (+ ev-av-fail 1))
|
||||||
|
(append!
|
||||||
|
ev-av-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got))))))
|
||||||
|
|
||||||
|
;; Fixture: three occurrences on 2026-06-01.
|
||||||
|
;; standup 09:00–09:30 review 09:15–10:15 (overlaps standup)
|
||||||
|
;; lunch 12:00–13:00
|
||||||
|
(define
|
||||||
|
ev-av-occs
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(list
|
||||||
|
(ev-occ
|
||||||
|
(quote standup)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
30)
|
||||||
|
(ev-occ
|
||||||
|
(quote review)
|
||||||
|
(ev-dt 2026 6 1 9 15)
|
||||||
|
60)
|
||||||
|
(ev-occ
|
||||||
|
(quote lunch)
|
||||||
|
(ev-dt 2026 6 1 12 0)
|
||||||
|
60))))
|
||||||
|
|
||||||
|
(define ev-av-key (fn (id start) (str id "@" start)))
|
||||||
|
|
||||||
|
;; alice: standup + review (overlap → conflict). bob: lunch only.
|
||||||
|
(define
|
||||||
|
ev-av-db
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(ev-avail-db
|
||||||
|
(ev-av-occs)
|
||||||
|
(list
|
||||||
|
(list
|
||||||
|
(quote alice)
|
||||||
|
(ev-av-key
|
||||||
|
(quote standup)
|
||||||
|
(ev-dt 2026 6 1 9 0)))
|
||||||
|
(list
|
||||||
|
(quote alice)
|
||||||
|
(ev-av-key
|
||||||
|
(quote review)
|
||||||
|
(ev-dt 2026 6 1 9 15)))
|
||||||
|
(list
|
||||||
|
(quote bob)
|
||||||
|
(ev-av-key
|
||||||
|
(quote lunch)
|
||||||
|
(ev-dt 2026 6 1 12 0)))))))
|
||||||
|
|
||||||
|
;; Disjoint fixture for slot search: 09:00–10:00 then 10:30–11:30 (a 30m gap).
|
||||||
|
(define
|
||||||
|
ev-av-gap-db
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(ev-avail-db
|
||||||
|
(list
|
||||||
|
(ev-occ
|
||||||
|
(quote a)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
60)
|
||||||
|
(ev-occ
|
||||||
|
(quote b)
|
||||||
|
(ev-dt 2026 6 1 10 30)
|
||||||
|
60))
|
||||||
|
(list
|
||||||
|
(list
|
||||||
|
(quote sam)
|
||||||
|
(ev-av-key
|
||||||
|
(quote a)
|
||||||
|
(ev-dt 2026 6 1 9 0)))
|
||||||
|
(list
|
||||||
|
(quote sam)
|
||||||
|
(ev-av-key
|
||||||
|
(quote b)
|
||||||
|
(ev-dt 2026 6 1 10 30)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-av-run-all!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(let
|
||||||
|
((db (ev-av-db)))
|
||||||
|
(do
|
||||||
|
(ev-av-check!
|
||||||
|
"busy lists alice committed intervals ascending"
|
||||||
|
(ev-busy db (quote alice))
|
||||||
|
(list
|
||||||
|
(list
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
(ev-dt 2026 6 1 9 30))
|
||||||
|
(list
|
||||||
|
(ev-dt 2026 6 1 9 15)
|
||||||
|
(ev-dt 2026 6 1 10 15))))
|
||||||
|
(ev-av-check!
|
||||||
|
"busy lists bob single interval"
|
||||||
|
(ev-busy db (quote bob))
|
||||||
|
(list
|
||||||
|
(list
|
||||||
|
(ev-dt 2026 6 1 12 0)
|
||||||
|
(ev-dt 2026 6 1 13 0))))
|
||||||
|
(ev-av-check!
|
||||||
|
"busy empty for unknown actor"
|
||||||
|
(ev-busy db (quote carol))
|
||||||
|
(list))
|
||||||
|
(ev-av-check!
|
||||||
|
"alice has an overlap conflict"
|
||||||
|
(ev-has-conflict? db (quote alice))
|
||||||
|
true)
|
||||||
|
(ev-av-check!
|
||||||
|
"alice conflict reported once (canonical pair)"
|
||||||
|
(len (ev-conflicts db (quote alice)))
|
||||||
|
1)
|
||||||
|
(ev-av-check!
|
||||||
|
"bob has no conflict"
|
||||||
|
(ev-has-conflict? db (quote bob))
|
||||||
|
false)
|
||||||
|
(ev-av-check!
|
||||||
|
"non-overlapping bookings do not conflict"
|
||||||
|
(ev-has-conflict?
|
||||||
|
(ev-avail-db
|
||||||
|
(list
|
||||||
|
(ev-occ
|
||||||
|
(quote a)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
9
|
||||||
|
0)
|
||||||
|
30)
|
||||||
|
(ev-occ
|
||||||
|
(quote b)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
9
|
||||||
|
30)
|
||||||
|
30))
|
||||||
|
(list
|
||||||
|
(list
|
||||||
|
(quote dave)
|
||||||
|
(ev-av-key
|
||||||
|
(quote a)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
9
|
||||||
|
0)))
|
||||||
|
(list
|
||||||
|
(quote dave)
|
||||||
|
(ev-av-key
|
||||||
|
(quote b)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
9
|
||||||
|
30)))))
|
||||||
|
(quote dave))
|
||||||
|
false)
|
||||||
|
(ev-av-check!
|
||||||
|
"alice free in an empty window"
|
||||||
|
(ev-free?
|
||||||
|
db
|
||||||
|
(quote alice)
|
||||||
|
(ev-dt 2026 6 1 13 0)
|
||||||
|
(ev-dt 2026 6 1 14 0))
|
||||||
|
true)
|
||||||
|
(ev-av-check!
|
||||||
|
"alice not free overlapping a booking"
|
||||||
|
(ev-free?
|
||||||
|
db
|
||||||
|
(quote alice)
|
||||||
|
(ev-dt 2026 6 1 9 20)
|
||||||
|
(ev-dt 2026 6 1 9 40))
|
||||||
|
false)
|
||||||
|
(ev-av-check!
|
||||||
|
"free? is half-open at the trailing edge"
|
||||||
|
(ev-free?
|
||||||
|
db
|
||||||
|
(quote alice)
|
||||||
|
(ev-dt 2026 6 1 10 15)
|
||||||
|
(ev-dt 2026 6 1 11 0))
|
||||||
|
true)
|
||||||
|
(ev-av-check!
|
||||||
|
"free? is half-open at the leading edge"
|
||||||
|
(ev-free?
|
||||||
|
db
|
||||||
|
(quote bob)
|
||||||
|
(ev-dt 2026 6 1 11 0)
|
||||||
|
(ev-dt 2026 6 1 12 0))
|
||||||
|
true)
|
||||||
|
(ev-av-check!
|
||||||
|
"free? false when window straddles a booking edge"
|
||||||
|
(ev-free?
|
||||||
|
db
|
||||||
|
(quote bob)
|
||||||
|
(ev-dt 2026 6 1 11 0)
|
||||||
|
(ev-dt 2026 6 1 12 1))
|
||||||
|
false)
|
||||||
|
(ev-av-check!
|
||||||
|
"free? query leaves db reusable (no leaked qwindow)"
|
||||||
|
(do
|
||||||
|
(ev-free?
|
||||||
|
db
|
||||||
|
(quote alice)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
(ev-dt 2026 6 1 9 30))
|
||||||
|
(ev-busy db (quote bob)))
|
||||||
|
(list
|
||||||
|
(list
|
||||||
|
(ev-dt 2026 6 1 12 0)
|
||||||
|
(ev-dt 2026 6 1 13 0))))
|
||||||
|
(let
|
||||||
|
((gdb (ev-av-gap-db)))
|
||||||
|
(do
|
||||||
|
(ev-av-check!
|
||||||
|
"next-free finds the gap between bookings"
|
||||||
|
(ev-next-free
|
||||||
|
gdb
|
||||||
|
(quote sam)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
30
|
||||||
|
(ev-dt 2026 6 1 18 0))
|
||||||
|
(ev-dt 2026 6 1 10 0))
|
||||||
|
(ev-av-check!
|
||||||
|
"next-free skips a gap too short for the duration"
|
||||||
|
(ev-next-free
|
||||||
|
gdb
|
||||||
|
(quote sam)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
60
|
||||||
|
(ev-dt 2026 6 1 18 0))
|
||||||
|
(ev-dt 2026 6 1 11 30))
|
||||||
|
(ev-av-check!
|
||||||
|
"next-free returns `after` when already free"
|
||||||
|
(ev-next-free
|
||||||
|
gdb
|
||||||
|
(quote sam)
|
||||||
|
(ev-dt 2026 6 1 14 0)
|
||||||
|
60
|
||||||
|
(ev-dt 2026 6 1 18 0))
|
||||||
|
(ev-dt 2026 6 1 14 0))
|
||||||
|
(ev-av-check!
|
||||||
|
"next-free returns nil when nothing fits before horizon"
|
||||||
|
(ev-next-free
|
||||||
|
gdb
|
||||||
|
(quote sam)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
120
|
||||||
|
(ev-dt 2026 6 1 11 0))
|
||||||
|
nil)
|
||||||
|
(ev-av-check!
|
||||||
|
"next-free for actor with no bookings is `after`"
|
||||||
|
(ev-next-free
|
||||||
|
gdb
|
||||||
|
(quote nobody)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
60
|
||||||
|
(ev-dt 2026 6 1 18 0))
|
||||||
|
(ev-dt 2026 6 1 9 0))
|
||||||
|
(ev-av-check!
|
||||||
|
"next-free at exact edge of a booking (half-open)"
|
||||||
|
(ev-next-free
|
||||||
|
gdb
|
||||||
|
(quote sam)
|
||||||
|
(ev-dt 2026 6 1 10 0)
|
||||||
|
30
|
||||||
|
(ev-dt 2026 6 1 18 0))
|
||||||
|
(ev-dt 2026 6 1 10 0))))
|
||||||
|
(let
|
||||||
|
((daily (ev-expand (ev-event (quote class) (ev-dt 2026 6 1 9 0) 60 {:freq :daily :count 3} 1) (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||||
|
(let
|
||||||
|
((db2 (ev-avail-db daily (map (fn (o) (list (quote sam) (ev-occ-key o))) daily))))
|
||||||
|
(do
|
||||||
|
(ev-av-check!
|
||||||
|
"expanded daily occurrences become busy intervals"
|
||||||
|
(len (ev-busy db2 (quote sam)))
|
||||||
|
3)
|
||||||
|
(ev-av-check!
|
||||||
|
"no conflicts among disjoint daily occurrences"
|
||||||
|
(ev-has-conflict? db2 (quote sam))
|
||||||
|
false)
|
||||||
|
(ev-av-check!
|
||||||
|
"busy on day two of the series"
|
||||||
|
(ev-free?
|
||||||
|
db2
|
||||||
|
(quote sam)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
2
|
||||||
|
9
|
||||||
|
30)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
2
|
||||||
|
9
|
||||||
|
45))
|
||||||
|
false))))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-availability-tests-run!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(set! ev-av-pass 0)
|
||||||
|
(set! ev-av-fail 0)
|
||||||
|
(set! ev-av-failures (list))
|
||||||
|
(ev-av-run-all!)
|
||||||
|
{:failures ev-av-failures :total (+ ev-av-pass ev-av-fail) :passed ev-av-pass :failed ev-av-fail})))
|
||||||
371
lib/events/tests/booking.sx
Normal file
371
lib/events/tests/booking.sx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
;; lib/events/tests/booking.sx — capacity-safe booking, cancel, and holds.
|
||||||
|
|
||||||
|
(define ev-bk-pass 0)
|
||||||
|
(define ev-bk-fail 0)
|
||||||
|
(define ev-bk-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-bk-check!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! ev-bk-pass (+ ev-bk-pass 1))
|
||||||
|
(do
|
||||||
|
(set! ev-bk-fail (+ ev-bk-fail 1))
|
||||||
|
(append!
|
||||||
|
ev-bk-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got))))))
|
||||||
|
|
||||||
|
;; Take a consistent (roster, last-seq) snapshot of an occurrence's stream.
|
||||||
|
(define ev-bk-snap (fn (b k) (ev-booked-actors b k)))
|
||||||
|
(define ev-bk-seq (fn (b k) (persist/last-seq b (ev-booking-stream k))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-bk-run-all!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev-bk-check!
|
||||||
|
"first booking takes seat 1"
|
||||||
|
(get (ev/book! b "o1" 3 (quote a)) :seat)
|
||||||
|
1)
|
||||||
|
(ev-bk-check!
|
||||||
|
"second booking takes seat 2"
|
||||||
|
(get (ev/book! b "o1" 3 (quote c)) :seat)
|
||||||
|
2)
|
||||||
|
(ev-bk-check!
|
||||||
|
"booked status reported"
|
||||||
|
(get (ev/book! b "o1" 3 (quote d)) :status)
|
||||||
|
:booked)
|
||||||
|
(ev-bk-check!
|
||||||
|
"roster is oldest-first"
|
||||||
|
(ev/roster b "o1")
|
||||||
|
(list (quote a) (quote c) (quote d)))
|
||||||
|
(ev-bk-check!
|
||||||
|
"seats-left is zero when full"
|
||||||
|
(ev/seats-left b "o1" 3)
|
||||||
|
0)
|
||||||
|
(ev-bk-check!
|
||||||
|
"free booking is confirmed state"
|
||||||
|
(ev/seat-state b "o1" (quote a))
|
||||||
|
:confirmed)))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/book! b "o2" 1 (quote a))
|
||||||
|
(ev-bk-check!
|
||||||
|
"booking past capacity is refused"
|
||||||
|
(get (ev/book! b "o2" 1 (quote c)) :status)
|
||||||
|
:full)
|
||||||
|
(ev-bk-check!
|
||||||
|
"full does not grow the roster"
|
||||||
|
(ev/roster b "o2")
|
||||||
|
(list (quote a)))
|
||||||
|
(ev-bk-check!
|
||||||
|
"seats-left zero at capacity"
|
||||||
|
(ev/seats-left b "o2" 1)
|
||||||
|
0)))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/book! b "o3" 5 (quote a))
|
||||||
|
(ev-bk-check!
|
||||||
|
"re-booking the same actor is idempotent"
|
||||||
|
(get (ev/book! b "o3" 5 (quote a)) :status)
|
||||||
|
:already)
|
||||||
|
(ev-bk-check!
|
||||||
|
"idempotent re-book reports existing seat"
|
||||||
|
(get (ev/book! b "o3" 5 (quote a)) :seat)
|
||||||
|
1)
|
||||||
|
(ev-bk-check!
|
||||||
|
"roster unchanged after re-book"
|
||||||
|
(ev/roster b "o3")
|
||||||
|
(list (quote a)))
|
||||||
|
(ev-bk-check!
|
||||||
|
"count unchanged after re-book"
|
||||||
|
(ev-booking-count b "o3")
|
||||||
|
1)))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/book! b "last" 2 (quote x))
|
||||||
|
(let
|
||||||
|
((snap (ev-bk-snap b "last")) (exp (ev-bk-seq b "last")))
|
||||||
|
(let
|
||||||
|
((ra (ev/book-with-observed b "last" 2 (quote a) snap exp))
|
||||||
|
(rb
|
||||||
|
(ev/book-with-observed
|
||||||
|
b
|
||||||
|
"last"
|
||||||
|
2
|
||||||
|
(quote bee)
|
||||||
|
snap
|
||||||
|
exp)))
|
||||||
|
(do
|
||||||
|
(ev-bk-check!
|
||||||
|
"race winner is booked"
|
||||||
|
(get ra :status)
|
||||||
|
:booked)
|
||||||
|
(ev-bk-check!
|
||||||
|
"race winner takes the last seat"
|
||||||
|
(get ra :seat)
|
||||||
|
2)
|
||||||
|
(ev-bk-check!
|
||||||
|
"race loser is rejected with a conflict"
|
||||||
|
(get rb :status)
|
||||||
|
:conflict)
|
||||||
|
(ev-bk-check!
|
||||||
|
"conflict reports the advanced seq"
|
||||||
|
(get rb :actual)
|
||||||
|
(+ exp 1))
|
||||||
|
(ev-bk-check!
|
||||||
|
"no overbooking: exactly two on roster"
|
||||||
|
(ev-booking-count b "last")
|
||||||
|
2)
|
||||||
|
(ev-bk-check!
|
||||||
|
"race loser is NOT on the roster"
|
||||||
|
(ev-bk-member? (quote bee) (ev/roster b "last"))
|
||||||
|
false)
|
||||||
|
(ev-bk-check!
|
||||||
|
"race loser retrying gets full"
|
||||||
|
(get (ev/book! b "last" 2 (quote bee)) :status)
|
||||||
|
:full))))))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/book! b "room" 3 (quote x))
|
||||||
|
(let
|
||||||
|
((snap (ev-bk-snap b "room")) (exp (ev-bk-seq b "room")))
|
||||||
|
(let
|
||||||
|
((ra (ev/book-with-observed b "room" 3 (quote a) snap exp))
|
||||||
|
(rb
|
||||||
|
(ev/book-with-observed
|
||||||
|
b
|
||||||
|
"room"
|
||||||
|
3
|
||||||
|
(quote bee)
|
||||||
|
snap
|
||||||
|
exp)))
|
||||||
|
(do
|
||||||
|
(ev-bk-check!
|
||||||
|
"room winner booked seat 2"
|
||||||
|
(get ra :seat)
|
||||||
|
2)
|
||||||
|
(ev-bk-check!
|
||||||
|
"room loser first conflicts"
|
||||||
|
(get rb :status)
|
||||||
|
:conflict)
|
||||||
|
(ev-bk-check!
|
||||||
|
"room loser retry books seat 3"
|
||||||
|
(get (ev/book! b "room" 3 (quote bee)) :seat)
|
||||||
|
3)
|
||||||
|
(ev-bk-check!
|
||||||
|
"room roster is x,a,bee"
|
||||||
|
(ev/roster b "room")
|
||||||
|
(list (quote x) (quote a) (quote bee)))
|
||||||
|
(ev-bk-check!
|
||||||
|
"room is now full"
|
||||||
|
(ev/seats-left b "room" 3)
|
||||||
|
0))))))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/book! b "cx" 2 (quote a))
|
||||||
|
(ev/book! b "cx" 2 (quote c))
|
||||||
|
(ev-bk-check!
|
||||||
|
"occupied to capacity before cancel"
|
||||||
|
(ev/seats-left b "cx" 2)
|
||||||
|
0)
|
||||||
|
(ev-bk-check!
|
||||||
|
"booking when full (pre-cancel) is refused"
|
||||||
|
(get (ev/book! b "cx" 2 (quote d)) :status)
|
||||||
|
:full)
|
||||||
|
(ev-bk-check!
|
||||||
|
"cancel reports cancelled"
|
||||||
|
(get (ev/cancel! b "cx" (quote a)) :status)
|
||||||
|
:cancelled)
|
||||||
|
(ev-bk-check!
|
||||||
|
"cancel removes actor from roster"
|
||||||
|
(ev/roster b "cx")
|
||||||
|
(list (quote c)))
|
||||||
|
(ev-bk-check!
|
||||||
|
"cancel frees a seat"
|
||||||
|
(ev/seats-left b "cx" 2)
|
||||||
|
1)
|
||||||
|
(ev-bk-check!
|
||||||
|
"freed seat is bookable again"
|
||||||
|
(get (ev/book! b "cx" 2 (quote d)) :status)
|
||||||
|
:booked)
|
||||||
|
(ev-bk-check!
|
||||||
|
"roster after rebook is c,d"
|
||||||
|
(ev/roster b "cx")
|
||||||
|
(list (quote c) (quote d)))))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/book! b "ce" 3 (quote a))
|
||||||
|
(ev-bk-check!
|
||||||
|
"cancelling an unbooked actor is a no-op"
|
||||||
|
(get (ev/cancel! b "ce" (quote z)) :status)
|
||||||
|
:not-booked)
|
||||||
|
(ev-bk-check!
|
||||||
|
"no-op cancel leaves roster intact"
|
||||||
|
(ev/roster b "ce")
|
||||||
|
(list (quote a)))
|
||||||
|
(ev/cancel! b "ce" (quote a))
|
||||||
|
(ev-bk-check!
|
||||||
|
"double cancel is not-booked the second time"
|
||||||
|
(get (ev/cancel! b "ce" (quote a)) :status)
|
||||||
|
:not-booked)
|
||||||
|
(ev-bk-check!
|
||||||
|
"empty roster after cancel"
|
||||||
|
(ev/roster b "ce")
|
||||||
|
(list))
|
||||||
|
(ev-bk-check!
|
||||||
|
"cancelled actor may re-book"
|
||||||
|
(get (ev/book! b "ce" 3 (quote a)) :status)
|
||||||
|
:booked)
|
||||||
|
(ev-bk-check!
|
||||||
|
"re-booked actor back on roster"
|
||||||
|
(ev/roster b "ce")
|
||||||
|
(list (quote a)))))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/book! b "h" 2 (quote a))
|
||||||
|
(ev-bk-check!
|
||||||
|
"hold reports held"
|
||||||
|
(get (ev/hold! b "h" 2 (quote p)) :status)
|
||||||
|
:held)
|
||||||
|
(ev-bk-check!
|
||||||
|
"held seat is :held state"
|
||||||
|
(ev/seat-state b "h" (quote p))
|
||||||
|
:held)
|
||||||
|
(ev-bk-check!
|
||||||
|
"held actor is on the roster"
|
||||||
|
(ev/roster b "h")
|
||||||
|
(list (quote a) (quote p)))
|
||||||
|
(ev-bk-check!
|
||||||
|
"held seat blocks the last booking"
|
||||||
|
(get (ev/book! b "h" 2 (quote x)) :status)
|
||||||
|
:full)
|
||||||
|
(ev-bk-check!
|
||||||
|
"no seats left with one held"
|
||||||
|
(ev/seats-left b "h" 2)
|
||||||
|
0)))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/hold! b "hc" 3 (quote p))
|
||||||
|
(ev-bk-check!
|
||||||
|
"confirm reports confirmed"
|
||||||
|
(get (ev/confirm! b "hc" (quote p)) :status)
|
||||||
|
:confirmed)
|
||||||
|
(ev-bk-check!
|
||||||
|
"confirmed seat is :confirmed state"
|
||||||
|
(ev/seat-state b "hc" (quote p))
|
||||||
|
:confirmed)
|
||||||
|
(ev-bk-check!
|
||||||
|
"re-confirm is already-confirmed"
|
||||||
|
(get (ev/confirm! b "hc" (quote p)) :status)
|
||||||
|
:already-confirmed)
|
||||||
|
(ev-bk-check!
|
||||||
|
"confirming a non-holder is not-held"
|
||||||
|
(get (ev/confirm! b "hc" (quote z)) :status)
|
||||||
|
:not-held)
|
||||||
|
(ev-bk-check!
|
||||||
|
"confirmed seat still occupies"
|
||||||
|
(ev/seats-left b "hc" 3)
|
||||||
|
2)))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/book! b "hr" 2 (quote a))
|
||||||
|
(ev/hold! b "hr" 2 (quote p))
|
||||||
|
(ev-bk-check!
|
||||||
|
"full while hold pending"
|
||||||
|
(ev/seats-left b "hr" 2)
|
||||||
|
0)
|
||||||
|
(ev-bk-check!
|
||||||
|
"release reports released"
|
||||||
|
(get (ev/release! b "hr" (quote p)) :status)
|
||||||
|
:released)
|
||||||
|
(ev-bk-check!
|
||||||
|
"release frees the held seat"
|
||||||
|
(ev/seats-left b "hr" 2)
|
||||||
|
1)
|
||||||
|
(ev-bk-check!
|
||||||
|
"released actor off the roster"
|
||||||
|
(ev/roster b "hr")
|
||||||
|
(list (quote a)))
|
||||||
|
(ev-bk-check!
|
||||||
|
"freed seat bookable after release"
|
||||||
|
(get (ev/book! b "hr" 2 (quote x)) :status)
|
||||||
|
:booked)
|
||||||
|
(ev/hold! b "hr2" 1 (quote q))
|
||||||
|
(ev/confirm! b "hr2" (quote q))
|
||||||
|
(ev-bk-check!
|
||||||
|
"release on a confirmed seat is not-held"
|
||||||
|
(get (ev/release! b "hr2" (quote q)) :status)
|
||||||
|
:not-held)
|
||||||
|
(ev-bk-check!
|
||||||
|
"cancel frees a confirmed-from-hold seat"
|
||||||
|
(get (ev/cancel! b "hr2" (quote q)) :status)
|
||||||
|
:cancelled)))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/book! b "hlast" 2 (quote x))
|
||||||
|
(let
|
||||||
|
((snap (ev-bk-snap b "hlast")) (exp (ev-bk-seq b "hlast")))
|
||||||
|
(let
|
||||||
|
((ra (ev/hold-with-observed b "hlast" 2 (quote p) snap exp))
|
||||||
|
(rb
|
||||||
|
(ev/hold-with-observed
|
||||||
|
b
|
||||||
|
"hlast"
|
||||||
|
2
|
||||||
|
(quote q)
|
||||||
|
snap
|
||||||
|
exp)))
|
||||||
|
(do
|
||||||
|
(ev-bk-check! "hold race winner held" (get ra :status) :held)
|
||||||
|
(ev-bk-check!
|
||||||
|
"hold race loser conflicts"
|
||||||
|
(get rb :status)
|
||||||
|
:conflict)
|
||||||
|
(ev-bk-check!
|
||||||
|
"no oversell via concurrent holds"
|
||||||
|
(ev-booking-count b "hlast")
|
||||||
|
2)
|
||||||
|
(ev-bk-check!
|
||||||
|
"hold loser retry gets full"
|
||||||
|
(get (ev/hold! b "hlast" 2 (quote q)) :status)
|
||||||
|
:full))))))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/hold! b "hi" 4 (quote p))
|
||||||
|
(ev-bk-check!
|
||||||
|
"re-holding the same actor is idempotent"
|
||||||
|
(get (ev/hold! b "hi" 4 (quote p)) :status)
|
||||||
|
:already)
|
||||||
|
(ev-bk-check!
|
||||||
|
"hold idempotency keeps one seat"
|
||||||
|
(ev-booking-count b "hi")
|
||||||
|
1))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-booking-tests-run!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(set! ev-bk-pass 0)
|
||||||
|
(set! ev-bk-fail 0)
|
||||||
|
(set! ev-bk-failures (list))
|
||||||
|
(ev-bk-run-all!)
|
||||||
|
{:failures ev-bk-failures :total (+ ev-bk-pass ev-bk-fail) :passed ev-bk-pass :failed ev-bk-fail})))
|
||||||
433
lib/events/tests/calendar.sx
Normal file
433
lib/events/tests/calendar.sx
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
;; lib/events/tests/calendar.sx — civil date core + RRULE window expansion.
|
||||||
|
|
||||||
|
(define ev-cal-pass 0)
|
||||||
|
(define ev-cal-fail 0)
|
||||||
|
(define ev-cal-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-cal-check!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! ev-cal-pass (+ ev-cal-pass 1))
|
||||||
|
(do
|
||||||
|
(set! ev-cal-fail (+ ev-cal-fail 1))
|
||||||
|
(append!
|
||||||
|
ev-cal-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got))))))
|
||||||
|
|
||||||
|
;; Project occurrences to (civil weekday) pairs for legible assertions.
|
||||||
|
(define
|
||||||
|
ev-cal-shape
|
||||||
|
(fn
|
||||||
|
(occs)
|
||||||
|
(map
|
||||||
|
(fn
|
||||||
|
(o)
|
||||||
|
(list (ev-dt->civil (get o :start)) (ev-dt-weekday (get o :start))))
|
||||||
|
occs)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-cal-starts
|
||||||
|
(fn (occs) (map (fn (o) (ev-dt->civil (get o :start))) occs)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-cal-run-all!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(ev-cal-check!
|
||||||
|
"epoch day zero"
|
||||||
|
(ev-days-from-civil 1970 1 1)
|
||||||
|
0)
|
||||||
|
(ev-cal-check!
|
||||||
|
"y2k day number"
|
||||||
|
(ev-days-from-civil 2000 1 1)
|
||||||
|
10957)
|
||||||
|
(ev-cal-check!
|
||||||
|
"leap day round trip"
|
||||||
|
(ev-civil-from-days
|
||||||
|
(ev-days-from-civil 2024 2 29))
|
||||||
|
(list 2024 2 29))
|
||||||
|
(ev-cal-check!
|
||||||
|
"pre-epoch round trip"
|
||||||
|
(ev-civil-from-days
|
||||||
|
(ev-days-from-civil 1969 12 31))
|
||||||
|
(list 1969 12 31))
|
||||||
|
(ev-cal-check!
|
||||||
|
"epoch is thursday"
|
||||||
|
(ev-weekday-of-days 0)
|
||||||
|
3)
|
||||||
|
(ev-cal-check!
|
||||||
|
"2026-06-06 is saturday"
|
||||||
|
(ev-dt-weekday (ev-date 2026 6 6))
|
||||||
|
5)
|
||||||
|
(ev-cal-check!
|
||||||
|
"dt carries time of day"
|
||||||
|
(ev-dt-tod
|
||||||
|
(ev-dt 2026 6 1 9 30))
|
||||||
|
570)
|
||||||
|
(ev-cal-check!
|
||||||
|
"civil from dt"
|
||||||
|
(ev-dt->civil
|
||||||
|
(ev-dt 2026 12 25 8 0))
|
||||||
|
(list 2026 12 25))
|
||||||
|
(ev-cal-check!
|
||||||
|
"days in feb 2024 (leap)"
|
||||||
|
(ev-days-in-month 2024 2)
|
||||||
|
29)
|
||||||
|
(ev-cal-check!
|
||||||
|
"days in feb 2026"
|
||||||
|
(ev-days-in-month 2026 2)
|
||||||
|
28)
|
||||||
|
(ev-cal-check!
|
||||||
|
"add months wraps year"
|
||||||
|
(ev-add-months 2026 11 3)
|
||||||
|
(list 2027 2))
|
||||||
|
(ev-cal-check!
|
||||||
|
"add months within year"
|
||||||
|
(ev-add-months 2026 1 5)
|
||||||
|
(list 2026 6))
|
||||||
|
(let
|
||||||
|
((ev (ev-event (quote one) (ev-dt 2026 6 10 14 0) 60 nil 1)))
|
||||||
|
(do
|
||||||
|
(ev-cal-check!
|
||||||
|
"single inside window emits once"
|
||||||
|
(len
|
||||||
|
(ev-expand
|
||||||
|
ev
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1)))
|
||||||
|
1)
|
||||||
|
(ev-cal-check!
|
||||||
|
"single before window omitted"
|
||||||
|
(len
|
||||||
|
(ev-expand
|
||||||
|
ev
|
||||||
|
(ev-date 2026 7 1)
|
||||||
|
(ev-date 2026 8 1)))
|
||||||
|
0)
|
||||||
|
(ev-cal-check!
|
||||||
|
"single after window omitted"
|
||||||
|
(len
|
||||||
|
(ev-expand
|
||||||
|
ev
|
||||||
|
(ev-date 2026 1 1)
|
||||||
|
(ev-date 2026 2 1)))
|
||||||
|
0)
|
||||||
|
(ev-cal-check!
|
||||||
|
"occurrence end is start plus duration"
|
||||||
|
(get
|
||||||
|
(first
|
||||||
|
(ev-expand
|
||||||
|
ev
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1)))
|
||||||
|
:end)
|
||||||
|
(+
|
||||||
|
(ev-dt 2026 6 10 14 0)
|
||||||
|
60))))
|
||||||
|
(let
|
||||||
|
((daily (ev-event (quote d) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 5} 1)))
|
||||||
|
(do
|
||||||
|
(ev-cal-check!
|
||||||
|
"daily count caps occurrences"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
daily
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1)))
|
||||||
|
(list
|
||||||
|
(list 2026 6 1)
|
||||||
|
(list 2026 6 2)
|
||||||
|
(list 2026 6 3)
|
||||||
|
(list 2026 6 4)
|
||||||
|
(list 2026 6 5)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"daily preserves time of day"
|
||||||
|
(ev-dt-tod
|
||||||
|
(get
|
||||||
|
(first
|
||||||
|
(ev-expand
|
||||||
|
daily
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1)))
|
||||||
|
:start))
|
||||||
|
540)))
|
||||||
|
(let
|
||||||
|
((di (ev-event (quote di) (ev-dt 2026 6 1 0 0) 30 {:interval 3 :freq :daily :until (ev-date 2026 6 30)} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"daily interval 3 steps by three days"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
di
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 6 13)))
|
||||||
|
(list
|
||||||
|
(list 2026 6 1)
|
||||||
|
(list 2026 6 4)
|
||||||
|
(list 2026 6 7)
|
||||||
|
(list 2026 6 10)
|
||||||
|
(list 2026 6 13))))
|
||||||
|
(let
|
||||||
|
((dc (ev-event (quote dc) (ev-dt 2026 6 1 0 0) 30 {:freq :daily :count 10} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"count is window-independent (clip middle)"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
dc
|
||||||
|
(ev-date 2026 6 5)
|
||||||
|
(ev-date 2026 6 8)))
|
||||||
|
(list
|
||||||
|
(list 2026 6 5)
|
||||||
|
(list 2026 6 6)
|
||||||
|
(list 2026 6 7)
|
||||||
|
(list 2026 6 8))))
|
||||||
|
(let
|
||||||
|
((dc2 (ev-event (quote dc2) (ev-dt 2026 6 1 0 0) 30 {:freq :daily :count 3} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"count exhausted before window yields nothing"
|
||||||
|
(len
|
||||||
|
(ev-expand
|
||||||
|
dc2
|
||||||
|
(ev-date 2026 6 10)
|
||||||
|
(ev-date 2026 6 20)))
|
||||||
|
0))
|
||||||
|
(let
|
||||||
|
((wk (ev-event (quote w) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :byday (list 0 2 4)} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"weekly byday mon/wed/fri first two weeks"
|
||||||
|
(ev-cal-shape
|
||||||
|
(ev-expand
|
||||||
|
wk
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 6 13)))
|
||||||
|
(list
|
||||||
|
(list (list 2026 6 1) 0)
|
||||||
|
(list (list 2026 6 3) 2)
|
||||||
|
(list (list 2026 6 5) 4)
|
||||||
|
(list (list 2026 6 8) 0)
|
||||||
|
(list (list 2026 6 10) 2)
|
||||||
|
(list (list 2026 6 12) 4))))
|
||||||
|
(let
|
||||||
|
((wu (ev-event (quote wu) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :until (ev-dt 2026 6 10 23 0) :byday (list 0 2)} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"weekly until clips trailing occurrences"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
wu
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1)))
|
||||||
|
(list
|
||||||
|
(list 2026 6 1)
|
||||||
|
(list 2026 6 3)
|
||||||
|
(list 2026 6 8)
|
||||||
|
(list 2026 6 10))))
|
||||||
|
(let
|
||||||
|
((wi (ev-event (quote wi) (ev-dt 2026 6 1 18 0) 90 {:interval 2 :freq :weekly :byday (list 0)} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"weekly interval 2 skips alternate weeks"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
wi
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 6)))
|
||||||
|
(list
|
||||||
|
(list 2026 6 1)
|
||||||
|
(list 2026 6 15)
|
||||||
|
(list 2026 6 29))))
|
||||||
|
(let
|
||||||
|
((wd (ev-event (quote wd) (ev-dt 2026 6 3 12 0) 60 {:freq :weekly :count 3} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"weekly default byday is dtstart weekday"
|
||||||
|
(ev-cal-shape
|
||||||
|
(ev-expand
|
||||||
|
wd
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 8 1)))
|
||||||
|
(list
|
||||||
|
(list (list 2026 6 3) 2)
|
||||||
|
(list (list 2026 6 10) 2)
|
||||||
|
(list (list 2026 6 17) 2))))
|
||||||
|
(let
|
||||||
|
((wc (ev-event (quote wc) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :count 10 :byday (list 0 2)} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"weekly count window-independent (clip middle)"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
wc
|
||||||
|
(ev-date 2026 6 15)
|
||||||
|
(ev-date 2026 7 5)))
|
||||||
|
(list
|
||||||
|
(list 2026 6 15)
|
||||||
|
(list 2026 6 17)
|
||||||
|
(list 2026 6 22)
|
||||||
|
(list 2026 6 24)
|
||||||
|
(list 2026 6 29)
|
||||||
|
(list 2026 7 1))))
|
||||||
|
(let
|
||||||
|
((wf (ev-event (quote wf) (ev-dt 2026 6 3 18 0) 90 {:freq :weekly :count 4 :byday (list 0 2 4)} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"first week skips byday earlier than dtstart"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
wf
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1)))
|
||||||
|
(list
|
||||||
|
(list 2026 6 3)
|
||||||
|
(list 2026 6 5)
|
||||||
|
(list 2026 6 8)
|
||||||
|
(list 2026 6 10))))
|
||||||
|
(let
|
||||||
|
((md (ev-event (quote md) (ev-dt 2026 1 15 9 0) 60 {:bymonthday (list 15) :freq :monthly} 1)))
|
||||||
|
(do
|
||||||
|
(ev-cal-check!
|
||||||
|
"monthly bymonthday 15th"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
md
|
||||||
|
(ev-date 2026 1 1)
|
||||||
|
(ev-date 2026 4 1)))
|
||||||
|
(list
|
||||||
|
(list 2026 1 15)
|
||||||
|
(list 2026 2 15)
|
||||||
|
(list 2026 3 15)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"monthly preserves time of day"
|
||||||
|
(ev-dt-tod
|
||||||
|
(get
|
||||||
|
(first
|
||||||
|
(ev-expand
|
||||||
|
md
|
||||||
|
(ev-date 2026 1 1)
|
||||||
|
(ev-date 2026 4 1)))
|
||||||
|
:start))
|
||||||
|
540)))
|
||||||
|
(let
|
||||||
|
((mm (ev-event (quote mm) (ev-dt 2026 1 1 9 0) 60 {:bymonthday (list 1 15) :freq :monthly :count 4} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"monthly multiple bymonthday sorted within month"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
mm
|
||||||
|
(ev-date 2026 1 1)
|
||||||
|
(ev-date 2026 12 1)))
|
||||||
|
(list
|
||||||
|
(list 2026 1 1)
|
||||||
|
(list 2026 1 15)
|
||||||
|
(list 2026 2 1)
|
||||||
|
(list 2026 2 15))))
|
||||||
|
(let
|
||||||
|
((ml (ev-event (quote ml) (ev-dt 2026 1 31 9 0) 60 {:bymonthday (list -1) :freq :monthly} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"monthly bymonthday -1 is last day"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
ml
|
||||||
|
(ev-date 2026 1 1)
|
||||||
|
(ev-date 2026 4 1)))
|
||||||
|
(list
|
||||||
|
(list 2026 1 31)
|
||||||
|
(list 2026 2 28)
|
||||||
|
(list 2026 3 31))))
|
||||||
|
(let
|
||||||
|
((mn (ev-event (quote mn) (ev-dt 2026 1 1 9 0) 60 {:freq :monthly :byday (list {:ord 2 :wd 1})} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"monthly 2nd tuesday"
|
||||||
|
(ev-cal-shape
|
||||||
|
(ev-expand
|
||||||
|
mn
|
||||||
|
(ev-date 2026 1 1)
|
||||||
|
(ev-date 2026 4 1)))
|
||||||
|
(list
|
||||||
|
(list (list 2026 1 13) 1)
|
||||||
|
(list (list 2026 2 10) 1)
|
||||||
|
(list (list 2026 3 10) 1))))
|
||||||
|
(let
|
||||||
|
((mz (ev-event (quote mz) (ev-dt 2026 1 1 9 0) 60 {:freq :monthly :byday (list {:ord -1 :wd 4})} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"monthly last friday"
|
||||||
|
(ev-cal-shape
|
||||||
|
(ev-expand
|
||||||
|
mz
|
||||||
|
(ev-date 2026 1 1)
|
||||||
|
(ev-date 2026 4 1)))
|
||||||
|
(list
|
||||||
|
(list (list 2026 1 30) 4)
|
||||||
|
(list (list 2026 2 27) 4)
|
||||||
|
(list (list 2026 3 27) 4))))
|
||||||
|
(let
|
||||||
|
((m31 (ev-event (quote m31) (ev-dt 2026 1 31 9 0) 60 {:freq :monthly :count 4} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"monthly default day-of-month skips short months"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
m31
|
||||||
|
(ev-date 2026 1 1)
|
||||||
|
(ev-date 2026 12 1)))
|
||||||
|
(list
|
||||||
|
(list 2026 1 31)
|
||||||
|
(list 2026 3 31)
|
||||||
|
(list 2026 5 31)
|
||||||
|
(list 2026 7 31))))
|
||||||
|
(let
|
||||||
|
((mi (ev-event (quote mi) (ev-dt 2026 1 10 9 0) 60 {:interval 3 :freq :monthly :count 3} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"monthly interval 3 steps by quarter"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
mi
|
||||||
|
(ev-date 2026 1 1)
|
||||||
|
(ev-date 2027 1 1)))
|
||||||
|
(list
|
||||||
|
(list 2026 1 10)
|
||||||
|
(list 2026 4 10)
|
||||||
|
(list 2026 7 10))))
|
||||||
|
(let
|
||||||
|
((mc (ev-event (quote mc) (ev-dt 2026 1 5 9 0) 60 {:freq :monthly :count 12} 1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"monthly count window-independent (clip middle)"
|
||||||
|
(ev-cal-starts
|
||||||
|
(ev-expand
|
||||||
|
mc
|
||||||
|
(ev-date 2026 4 1)
|
||||||
|
(ev-date 2026 6 30)))
|
||||||
|
(list
|
||||||
|
(list 2026 4 5)
|
||||||
|
(list 2026 5 5)
|
||||||
|
(list 2026 6 5))))
|
||||||
|
(let
|
||||||
|
((a (ev-event (quote a) (ev-dt 2026 6 2 10 0) 30 {:freq :daily :count 2} 1))
|
||||||
|
(b
|
||||||
|
(ev-event
|
||||||
|
(quote b)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
30
|
||||||
|
{:freq :daily :count 2}
|
||||||
|
1)))
|
||||||
|
(ev-cal-check!
|
||||||
|
"expand-all sorts merged occurrences by start"
|
||||||
|
(map
|
||||||
|
(fn (o) (list (get o :id) (ev-dt->civil (get o :start))))
|
||||||
|
(ev-expand-all
|
||||||
|
(list a b)
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1)))
|
||||||
|
(list
|
||||||
|
(list (quote b) (list 2026 6 1))
|
||||||
|
(list (quote b) (list 2026 6 2))
|
||||||
|
(list (quote a) (list 2026 6 2))
|
||||||
|
(list (quote a) (list 2026 6 3))))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-calendar-tests-run!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(set! ev-cal-pass 0)
|
||||||
|
(set! ev-cal-fail 0)
|
||||||
|
(set! ev-cal-failures (list))
|
||||||
|
(ev-cal-run-all!)
|
||||||
|
{:failures ev-cal-failures :total (+ ev-cal-pass ev-cal-fail) :passed ev-cal-pass :failed ev-cal-fail})))
|
||||||
231
lib/events/tests/federation.sx
Normal file
231
lib/events/tests/federation.sx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
;; lib/events/tests/federation.sx — trust-gated cross-instance agenda merge.
|
||||||
|
|
||||||
|
(define ev-fd-pass 0)
|
||||||
|
(define ev-fd-fail 0)
|
||||||
|
(define ev-fd-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-fd-check!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! ev-fd-pass (+ ev-fd-pass 1))
|
||||||
|
(do
|
||||||
|
(set! ev-fd-fail (+ ev-fd-fail 1))
|
||||||
|
(append!
|
||||||
|
ev-fd-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got))))))
|
||||||
|
|
||||||
|
;; Local schedule + two peers. Distinct start times make ordering legible.
|
||||||
|
(define
|
||||||
|
ev-fd-local
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(ev/schedule
|
||||||
|
(ev/empty)
|
||||||
|
(quote yoga)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
60
|
||||||
|
nil
|
||||||
|
20)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-fd-berlin
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(ev/peer
|
||||||
|
(quote berlin)
|
||||||
|
(ev/schedule
|
||||||
|
(ev/empty)
|
||||||
|
(quote meetup)
|
||||||
|
(ev-dt 2026 6 1 12 0)
|
||||||
|
90
|
||||||
|
nil
|
||||||
|
100))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-fd-paris
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(ev/peer
|
||||||
|
(quote paris)
|
||||||
|
(ev/schedule
|
||||||
|
(ev/empty)
|
||||||
|
(quote salon)
|
||||||
|
(ev-dt 2026 6 1 15 0)
|
||||||
|
60
|
||||||
|
nil
|
||||||
|
30))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-fd-run-all!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(let
|
||||||
|
((local (ev-fd-local))
|
||||||
|
(peers (list (ev-fd-berlin) (ev-fd-paris)))
|
||||||
|
(ws (ev-date 2026 6 1))
|
||||||
|
(we (ev-date 2026 6 2)))
|
||||||
|
(do
|
||||||
|
(ev-fd-check!
|
||||||
|
"trusts a peer in the trust set"
|
||||||
|
(ev/trusts? (list (quote berlin)) (quote berlin))
|
||||||
|
true)
|
||||||
|
(ev-fd-check!
|
||||||
|
"does not trust a peer outside the set"
|
||||||
|
(ev/trusts? (list (quote berlin)) (quote paris))
|
||||||
|
false)
|
||||||
|
(ev-fd-check!
|
||||||
|
"trusted-peers filters to the trust set"
|
||||||
|
(map ev/peer-id (ev/trusted-peers peers (list (quote berlin))))
|
||||||
|
(list (quote berlin)))
|
||||||
|
(let
|
||||||
|
((fed (ev/federated-agenda local peers (list (quote berlin)) ws we)))
|
||||||
|
(do
|
||||||
|
(ev-fd-check!
|
||||||
|
"merge includes local + trusted peer only"
|
||||||
|
(map (fn (o) (list (get o :origin) (get o :id))) fed)
|
||||||
|
(list
|
||||||
|
(list :local (quote yoga))
|
||||||
|
(list (quote berlin) (quote meetup))))
|
||||||
|
(ev-fd-check!
|
||||||
|
"merge is sorted by start"
|
||||||
|
(map (fn (o) (get o :start)) fed)
|
||||||
|
(list
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
(ev-dt 2026 6 1 12 0)))
|
||||||
|
(ev-fd-check!
|
||||||
|
"untrusted peer (paris) contributes nothing"
|
||||||
|
(len (ev/from-origin fed (quote paris)))
|
||||||
|
0)
|
||||||
|
(ev-fd-check!
|
||||||
|
"local occurrences tagged :local"
|
||||||
|
(map (fn (o) (get o :id)) (ev/from-origin fed :local))
|
||||||
|
(list (quote yoga)))
|
||||||
|
(ev-fd-check!
|
||||||
|
"peer occurrences tagged with the peer id"
|
||||||
|
(map
|
||||||
|
(fn (o) (get o :id))
|
||||||
|
(ev/from-origin fed (quote berlin)))
|
||||||
|
(list (quote meetup)))))
|
||||||
|
(let
|
||||||
|
((fed2 (ev/federated-agenda local peers (list (quote berlin) (quote paris)) ws we)))
|
||||||
|
(ev-fd-check!
|
||||||
|
"trusting both peers merges all three, sorted"
|
||||||
|
(map (fn (o) (list (get o :origin) (get o :id))) fed2)
|
||||||
|
(list
|
||||||
|
(list :local (quote yoga))
|
||||||
|
(list (quote berlin) (quote meetup))
|
||||||
|
(list (quote paris) (quote salon)))))
|
||||||
|
(let
|
||||||
|
((fed3 (ev/federated-agenda local peers (list) ws we)))
|
||||||
|
(do
|
||||||
|
(ev-fd-check!
|
||||||
|
"empty trust yields only local occurrences"
|
||||||
|
(map (fn (o) (get o :origin)) fed3)
|
||||||
|
(list :local))
|
||||||
|
(ev-fd-check!
|
||||||
|
"empty trust still includes local"
|
||||||
|
(len fed3)
|
||||||
|
1)))
|
||||||
|
(let
|
||||||
|
((rpeer (ev/peer (quote tokyo) (ev/schedule (ev/empty) (quote standup) (ev-dt 2026 6 1 8 0) 15 {:freq :daily :count 3} 5))))
|
||||||
|
(let
|
||||||
|
((pa (ev/peer-agenda rpeer ws (ev-date 2026 6 4))))
|
||||||
|
(do
|
||||||
|
(ev-fd-check!
|
||||||
|
"peer recurrence expands in the window"
|
||||||
|
(len pa)
|
||||||
|
3)
|
||||||
|
(ev-fd-check!
|
||||||
|
"every peer occurrence is tagged with the peer id"
|
||||||
|
(map (fn (o) (get o :origin)) pa)
|
||||||
|
(list (quote tokyo) (quote tokyo) (quote tokyo))))))))))
|
||||||
|
|
||||||
|
;; ---- federated free/busy ----
|
||||||
|
(define
|
||||||
|
ev-fd-fb-run-all!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(let
|
||||||
|
((local-db
|
||||||
|
(ev-avail-db
|
||||||
|
(list (ev-occ (quote yoga) (ev-dt 2026 6 1 9 0) 60))
|
||||||
|
(list (list (quote nia) (str (quote yoga) "@" (ev-dt 2026 6 1 9 0))))))
|
||||||
|
(berlin
|
||||||
|
(ev/peer-with-busy
|
||||||
|
(quote berlin)
|
||||||
|
(ev/empty)
|
||||||
|
(list
|
||||||
|
(list (quote nia)
|
||||||
|
(list (list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0)))))))
|
||||||
|
(paris
|
||||||
|
(ev/peer-with-busy
|
||||||
|
(quote paris)
|
||||||
|
(ev/empty)
|
||||||
|
(list
|
||||||
|
(list (quote nia)
|
||||||
|
(list (list (ev-dt 2026 6 1 11 0) (ev-dt 2026 6 1 12 0))))))))
|
||||||
|
(let
|
||||||
|
((peers (list berlin paris)))
|
||||||
|
(do
|
||||||
|
;; peer-busy reads a peer's published intervals
|
||||||
|
(ev-fd-check!
|
||||||
|
"peer-busy returns published intervals for an actor"
|
||||||
|
(ev/peer-busy berlin (quote nia))
|
||||||
|
(list (list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0))))
|
||||||
|
(ev-fd-check!
|
||||||
|
"peer-busy empty for an actor with nothing published"
|
||||||
|
(ev/peer-busy berlin (quote zed))
|
||||||
|
(list))
|
||||||
|
;; federated-busy unions local + trusted-peer busy, sorted
|
||||||
|
(ev-fd-check!
|
||||||
|
"federated-busy unions local + trusted peer, sorted"
|
||||||
|
(ev/federated-busy local-db (list berlin) (list (quote berlin)) (quote nia))
|
||||||
|
(list
|
||||||
|
(list (ev-dt 2026 6 1 9 0) (ev-dt 2026 6 1 10 0))
|
||||||
|
(list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0))))
|
||||||
|
(ev-fd-check!
|
||||||
|
"untrusted peer busy is excluded from federated-busy"
|
||||||
|
(ev/federated-busy local-db peers (list (quote berlin)) (quote nia))
|
||||||
|
(list
|
||||||
|
(list (ev-dt 2026 6 1 9 0) (ev-dt 2026 6 1 10 0))
|
||||||
|
(list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0))))
|
||||||
|
;; federated-free? considers both local and trusted-peer commitments
|
||||||
|
(ev-fd-check!
|
||||||
|
"free locally and on peers in an open window"
|
||||||
|
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 16 0) (ev-dt 2026 6 1 17 0))
|
||||||
|
true)
|
||||||
|
(ev-fd-check!
|
||||||
|
"not free during a LOCAL booking"
|
||||||
|
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 9 30) (ev-dt 2026 6 1 9 45))
|
||||||
|
false)
|
||||||
|
(ev-fd-check!
|
||||||
|
"not free during a TRUSTED PEER busy interval"
|
||||||
|
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 14 30) (ev-dt 2026 6 1 14 45))
|
||||||
|
false)
|
||||||
|
(ev-fd-check!
|
||||||
|
"free during an UNTRUSTED peer's busy interval (paris not trusted)"
|
||||||
|
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 11 15) (ev-dt 2026 6 1 11 45))
|
||||||
|
true)
|
||||||
|
(ev-fd-check!
|
||||||
|
"not free once paris is trusted too"
|
||||||
|
(ev/federated-free? local-db peers (list (quote berlin) (quote paris)) (quote nia) (ev-dt 2026 6 1 11 15) (ev-dt 2026 6 1 11 45))
|
||||||
|
false)
|
||||||
|
(ev-fd-check!
|
||||||
|
"federated-free? half-open at a busy edge"
|
||||||
|
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 15 0) (ev-dt 2026 6 1 16 0))
|
||||||
|
true))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-federation-tests-run!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(set! ev-fd-pass 0)
|
||||||
|
(set! ev-fd-fail 0)
|
||||||
|
(set! ev-fd-failures (list))
|
||||||
|
(ev-fd-run-all!)
|
||||||
|
(ev-fd-fb-run-all!)
|
||||||
|
{:failures ev-fd-failures :total (+ ev-fd-pass ev-fd-fail) :passed ev-fd-pass :failed ev-fd-fail})))
|
||||||
77
lib/events/tests/notify.sx
Normal file
77
lib/events/tests/notify.sx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
;; lib/events/tests/notify.sx — durable notification delivery flows.
|
||||||
|
|
||||||
|
(define ev-nt-pass 0)
|
||||||
|
(define ev-nt-fail 0)
|
||||||
|
(define ev-nt-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-nt-check!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! ev-nt-pass (+ ev-nt-pass 1))
|
||||||
|
(do
|
||||||
|
(set! ev-nt-fail (+ ev-nt-fail 1))
|
||||||
|
(append!
|
||||||
|
ev-nt-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got))))))
|
||||||
|
|
||||||
|
;; Each case runs a Scheme flow program (notify flows preloaded) and asserts on
|
||||||
|
;; the SX-native result. Scheme symbols come back as strings.
|
||||||
|
(define
|
||||||
|
ev-nt-run-all!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(ev-nt-check!
|
||||||
|
"reminder delivers on the first attempt"
|
||||||
|
(ev/notify-run
|
||||||
|
"(define s (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote alice) (quote hello))))\n (flow-run-host (lambda (k p) (list (quote ok) (quote sent))) 5)\n (list (flow/status (car (cdr s))) (flow/result (car (cdr s))))")
|
||||||
|
(list "done" (list "delivered" "m1" 1)))
|
||||||
|
(ev-nt-check!
|
||||||
|
"reminder retries a transient failure then delivers"
|
||||||
|
(ev/notify-run
|
||||||
|
"(define hits 0)\n (define s (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote bob) (quote hi))))\n (flow-run-host (lambda (k p) (begin (set! hits (+ hits 1)) (if (< hits 2) (list (quote retry) (quote down)) (list (quote ok) (quote sent))))) 10)\n (list (flow/result (car (cdr s))) hits)")
|
||||||
|
(list (list "delivered" "m1" 2) 2))
|
||||||
|
(ev-nt-check!
|
||||||
|
"reminder gives up after maxn attempts"
|
||||||
|
(ev/notify-run
|
||||||
|
"(define s (flow/start (ev-deliver-reminder 2) (list (quote m1) (quote x) (quote y))))\n (flow-run-host (lambda (k p) (list (quote retry) (quote down))) 10)\n (flow/result (car (cdr s)))")
|
||||||
|
(list "failed" "m1" "down"))
|
||||||
|
(ev-nt-check!
|
||||||
|
"redelivery of the same id sends only once (at-least-once, idempotent)"
|
||||||
|
(ev/notify-run
|
||||||
|
"(define sent (list)) (define deliveries 0)\n (define (xport k p)\n (let ((id (ev-msg-id p)))\n (if (ev-mem id sent)\n (list (quote ok) (quote duplicate))\n (begin (set! sent (cons id sent)) (set! deliveries (+ deliveries 1)) (list (quote ok) (quote sent))))))\n (define s1 (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote a) (quote hi))))\n (flow-run-host xport 5)\n (define s2 (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote a) (quote hi))))\n (flow-run-host xport 5)\n (list deliveries (flow/result (car (cdr s2))))")
|
||||||
|
(list 1 (list "delivered" "m1" 1)))
|
||||||
|
(ev-nt-check!
|
||||||
|
"digest delivers every message in the batch"
|
||||||
|
(ev/notify-run
|
||||||
|
"(define s (flow/start (ev-deliver-digest 3) (list (list (quote a) (quote u1) (quote hi)) (list (quote b) (quote u2) (quote yo)))))\n (flow-run-host (lambda (k p) (list (quote ok) (quote sent))) 10)\n (flow/result (car (cdr s)))")
|
||||||
|
(list
|
||||||
|
(list "delivered" "a" 1)
|
||||||
|
(list "delivered" "b" 1)))
|
||||||
|
(ev-nt-check!
|
||||||
|
"digest reports per-message outcomes independently"
|
||||||
|
(ev/notify-run
|
||||||
|
"(define (xport k p)\n (let ((id (ev-msg-id p)))\n (if (equal? id (quote b)) (list (quote retry) (quote flaky)) (list (quote ok) (quote sent)))))\n (define s (flow/start (ev-deliver-digest 2) (list (list (quote a) (quote u1) (quote hi)) (list (quote b) (quote u2) (quote yo)) (list (quote c) (quote u3) (quote ya)))))\n (flow-run-host xport 12)\n (flow/result (car (cdr s)))")
|
||||||
|
(list
|
||||||
|
(list "delivered" "a" 1)
|
||||||
|
(list "failed" "b" "flaky")
|
||||||
|
(list "delivered" "c" 1)))
|
||||||
|
(ev-nt-check!
|
||||||
|
"delivery suspends until the transport responds"
|
||||||
|
(ev/notify-run
|
||||||
|
"(define s (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote a) (quote hi))))\n (flow/status (car (cdr s)))")
|
||||||
|
"suspended"))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-notify-tests-run!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(set! ev-nt-pass 0)
|
||||||
|
(set! ev-nt-fail 0)
|
||||||
|
(set! ev-nt-failures (list))
|
||||||
|
(ev-nt-run-all!)
|
||||||
|
{:failures ev-nt-failures :total (+ ev-nt-pass ev-nt-fail) :passed ev-nt-pass :failed ev-nt-fail})))
|
||||||
220
lib/events/tests/reminders.sx
Normal file
220
lib/events/tests/reminders.sx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
;; lib/events/tests/reminders.sx — reminder + digest derivation from the agenda.
|
||||||
|
|
||||||
|
(define ev-rm-pass 0)
|
||||||
|
(define ev-rm-fail 0)
|
||||||
|
(define ev-rm-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-rm-check!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! ev-rm-pass (+ ev-rm-pass 1))
|
||||||
|
(do
|
||||||
|
(set! ev-rm-fail (+ ev-rm-fail 1))
|
||||||
|
(append!
|
||||||
|
ev-rm-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got))))))
|
||||||
|
|
||||||
|
;; A store with a weekly class (Mon+Wed 18:00, 60m, 4 occurrences) and a one-off
|
||||||
|
;; talk; durable bookings on a persist backend.
|
||||||
|
(define
|
||||||
|
ev-rm-store
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(ev/schedule
|
||||||
|
(ev/schedule
|
||||||
|
(ev/empty)
|
||||||
|
(quote yoga)
|
||||||
|
(ev-dt 2026 6 1 18 0)
|
||||||
|
60
|
||||||
|
{:freq :weekly :count 4 :byday (list 0 2)}
|
||||||
|
20)
|
||||||
|
(quote talk)
|
||||||
|
(ev-dt 2026 6 2 12 0)
|
||||||
|
30
|
||||||
|
nil
|
||||||
|
50)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-rm-run-all!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(let
|
||||||
|
((store (ev-rm-store)) (b (persist/open)))
|
||||||
|
(let
|
||||||
|
((occs (ev/agenda store (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||||
|
(do
|
||||||
|
(ev/book-occ! b store (quote nia) (first occs))
|
||||||
|
(ev/book-occ! b store (quote ola) (first occs))
|
||||||
|
(ev/book-occ!
|
||||||
|
b
|
||||||
|
store
|
||||||
|
(quote ola)
|
||||||
|
(ev-occ
|
||||||
|
(quote talk)
|
||||||
|
(ev-dt 2026 6 2 12 0)
|
||||||
|
30))
|
||||||
|
(do
|
||||||
|
(let
|
||||||
|
((rs (ev/occurrence-reminders b (first occs) 60)))
|
||||||
|
(do
|
||||||
|
(ev-rm-check!
|
||||||
|
"one reminder per booked attendee"
|
||||||
|
(len rs)
|
||||||
|
2)
|
||||||
|
(ev-rm-check!
|
||||||
|
"reminder fires lead minutes before start"
|
||||||
|
(get (first rs) :fire-at)
|
||||||
|
(-
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
18
|
||||||
|
0)
|
||||||
|
60))
|
||||||
|
(ev-rm-check!
|
||||||
|
"reminder idempotency key encodes occ/recipient/lead"
|
||||||
|
(get (first rs) :id)
|
||||||
|
(str
|
||||||
|
(ev-occ-key (first occs))
|
||||||
|
"/"
|
||||||
|
(quote nia)
|
||||||
|
"/"
|
||||||
|
60))
|
||||||
|
(ev-rm-check!
|
||||||
|
"reminder names the event"
|
||||||
|
(get (first rs) :event)
|
||||||
|
(quote yoga))))
|
||||||
|
(ev-rm-check!
|
||||||
|
"unbooked occurrence has no reminders"
|
||||||
|
(len
|
||||||
|
(ev/occurrence-reminders b (ev-occ (quote yoga) (ev-dt 2026 6 3 18 0) 60) 60))
|
||||||
|
0)
|
||||||
|
(let
|
||||||
|
((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60)))
|
||||||
|
(do
|
||||||
|
(ev-rm-check!
|
||||||
|
"agenda reminders cover all bookings"
|
||||||
|
(len all)
|
||||||
|
3)
|
||||||
|
(ev-rm-check!
|
||||||
|
"agenda reminders sorted by fire-at"
|
||||||
|
(map (fn (r) (get r :fire-at)) all)
|
||||||
|
(list
|
||||||
|
(-
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
18
|
||||||
|
0)
|
||||||
|
60)
|
||||||
|
(-
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
18
|
||||||
|
0)
|
||||||
|
60)
|
||||||
|
(-
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
2
|
||||||
|
12
|
||||||
|
0)
|
||||||
|
60)))))
|
||||||
|
(let
|
||||||
|
((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60)))
|
||||||
|
(do
|
||||||
|
(ev-rm-check!
|
||||||
|
"nothing due before the first fire-at"
|
||||||
|
(len
|
||||||
|
(ev/due-reminders
|
||||||
|
all
|
||||||
|
(-
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
17
|
||||||
|
0)
|
||||||
|
1)))
|
||||||
|
0)
|
||||||
|
(ev-rm-check!
|
||||||
|
"the two yoga reminders are due at 17:00"
|
||||||
|
(len
|
||||||
|
(ev/due-reminders
|
||||||
|
all
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
17
|
||||||
|
0)))
|
||||||
|
2)
|
||||||
|
(ev-rm-check!
|
||||||
|
"all reminders due once past the last fire-at"
|
||||||
|
(len
|
||||||
|
(ev/due-reminders
|
||||||
|
all
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
2
|
||||||
|
12
|
||||||
|
0)))
|
||||||
|
3)))
|
||||||
|
(let
|
||||||
|
((r (first (ev/occurrence-reminders b (first occs) 60))))
|
||||||
|
(ev-rm-check!
|
||||||
|
"reminder projects to (id recipient body)"
|
||||||
|
(ev/reminder->msg r)
|
||||||
|
(list
|
||||||
|
(str
|
||||||
|
(ev-occ-key (first occs))
|
||||||
|
"/"
|
||||||
|
(quote nia)
|
||||||
|
"/"
|
||||||
|
60)
|
||||||
|
(quote nia)
|
||||||
|
(list
|
||||||
|
:reminder (quote yoga)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
18
|
||||||
|
0)))))
|
||||||
|
(let
|
||||||
|
((dig (ev/agenda-digest b store (quote ola) (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||||
|
(do
|
||||||
|
(ev-rm-check!
|
||||||
|
"digest is addressed to the actor"
|
||||||
|
(get dig :recipient)
|
||||||
|
(quote ola))
|
||||||
|
(ev-rm-check!
|
||||||
|
"digest lists the actor's booked occurrences"
|
||||||
|
(map (fn (it) (get it :event)) (get dig :items))
|
||||||
|
(list (quote yoga) (quote talk)))))
|
||||||
|
(let
|
||||||
|
((empty-dig (ev/agenda-digest b store (quote nobody) (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||||
|
(ev-rm-check!
|
||||||
|
"digest empty for an actor with no bookings"
|
||||||
|
(get empty-dig :items)
|
||||||
|
(list)))))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-reminders-tests-run!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(set! ev-rm-pass 0)
|
||||||
|
(set! ev-rm-fail 0)
|
||||||
|
(set! ev-rm-failures (list))
|
||||||
|
(ev-rm-run-all!)
|
||||||
|
{:failures ev-rm-failures :total (+ ev-rm-pass ev-rm-fail) :passed ev-rm-pass :failed ev-rm-fail})))
|
||||||
252
lib/events/tests/ticket.sx
Normal file
252
lib/events/tests/ticket.sx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
;; lib/events/tests/ticket.sx — paid-ticket contract + settlement orchestration.
|
||||||
|
|
||||||
|
(define ev-tk-pass 0)
|
||||||
|
(define ev-tk-fail 0)
|
||||||
|
(define ev-tk-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-tk-check!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! ev-tk-pass (+ ev-tk-pass 1))
|
||||||
|
(do
|
||||||
|
(set! ev-tk-fail (+ ev-tk-fail 1))
|
||||||
|
(append!
|
||||||
|
ev-tk-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-tk-run-all!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(let
|
||||||
|
((req (ev/checkout-request "occ1" (quote nia) 1500 "GBP" "ref-1")))
|
||||||
|
(do
|
||||||
|
(ev-tk-check!
|
||||||
|
"checkout-request is tagged"
|
||||||
|
(ev/checkout-request? req)
|
||||||
|
true)
|
||||||
|
(ev-tk-check!
|
||||||
|
"payment-result is not a checkout-request"
|
||||||
|
(ev/checkout-request? (ev/payment-paid "o" (quote a) "r"))
|
||||||
|
false)
|
||||||
|
(ev-tk-check!
|
||||||
|
"request occ-key accessor"
|
||||||
|
(ev/req-occ-key req)
|
||||||
|
"occ1")
|
||||||
|
(ev-tk-check!
|
||||||
|
"request actor accessor"
|
||||||
|
(ev/req-actor req)
|
||||||
|
(quote nia))
|
||||||
|
(ev-tk-check!
|
||||||
|
"request amount accessor"
|
||||||
|
(ev/req-amount req)
|
||||||
|
1500)
|
||||||
|
(ev-tk-check!
|
||||||
|
"request currency accessor"
|
||||||
|
(ev/req-currency req)
|
||||||
|
"GBP")
|
||||||
|
(ev-tk-check! "request ref accessor" (ev/req-ref req) "ref-1")))
|
||||||
|
(let
|
||||||
|
((res (ev/payment-paid "occ1" (quote nia) "ref-1")))
|
||||||
|
(do
|
||||||
|
(ev-tk-check!
|
||||||
|
"payment-result is tagged"
|
||||||
|
(ev/payment-result? res)
|
||||||
|
true)
|
||||||
|
(ev-tk-check! "result status accessor" (ev/result-status res) :paid)
|
||||||
|
(ev-tk-check!
|
||||||
|
"failed constructor carries status"
|
||||||
|
(ev/result-status (ev/payment-failed "o" (quote a) "r"))
|
||||||
|
:failed)
|
||||||
|
(ev-tk-check!
|
||||||
|
"expired constructor carries status"
|
||||||
|
(ev/result-status (ev/payment-expired "o" (quote a) "r"))
|
||||||
|
:expired)))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(let
|
||||||
|
((r (ev/request-ticket! b "show" 1 (quote a) 2000 "GBP" "ref-a")))
|
||||||
|
(do
|
||||||
|
(ev-tk-check!
|
||||||
|
"request-ticket awaiting-payment"
|
||||||
|
(get r :status)
|
||||||
|
:awaiting-payment)
|
||||||
|
(ev-tk-check!
|
||||||
|
"request-ticket returns a checkout-request"
|
||||||
|
(ev/checkout-request? (get r :request))
|
||||||
|
true)
|
||||||
|
(ev-tk-check!
|
||||||
|
"checkout-request carries the amount"
|
||||||
|
(ev/req-amount (get r :request))
|
||||||
|
2000)))
|
||||||
|
(ev-tk-check!
|
||||||
|
"held seat reserves capacity"
|
||||||
|
(ev/seats-left b "show" 1)
|
||||||
|
0)
|
||||||
|
(ev-tk-check!
|
||||||
|
"second buyer is full while payment pends"
|
||||||
|
(get
|
||||||
|
(ev/request-ticket!
|
||||||
|
b
|
||||||
|
"show"
|
||||||
|
1
|
||||||
|
(quote c)
|
||||||
|
2000
|
||||||
|
"GBP"
|
||||||
|
"ref-c")
|
||||||
|
:status)
|
||||||
|
:full)
|
||||||
|
(ev-tk-check!
|
||||||
|
"held seat state pending"
|
||||||
|
(ev/seat-state b "show" (quote a))
|
||||||
|
:held)))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/request-ticket!
|
||||||
|
b
|
||||||
|
"gig"
|
||||||
|
2
|
||||||
|
(quote a)
|
||||||
|
2000
|
||||||
|
"GBP"
|
||||||
|
"ref-a")
|
||||||
|
(let
|
||||||
|
((s (ev/settle-payment! b (ev/payment-paid "gig" (quote a) "ref-a"))))
|
||||||
|
(ev-tk-check! "settle paid confirms" (get s :status) :confirmed))
|
||||||
|
(ev-tk-check!
|
||||||
|
"confirmed seat state"
|
||||||
|
(ev/seat-state b "gig" (quote a))
|
||||||
|
:confirmed)
|
||||||
|
(ev-tk-check!
|
||||||
|
"redelivered paid is still confirmed (idempotent)"
|
||||||
|
(get
|
||||||
|
(ev/settle-payment!
|
||||||
|
b
|
||||||
|
(ev/payment-paid "gig" (quote a) "ref-a"))
|
||||||
|
:status)
|
||||||
|
:confirmed)
|
||||||
|
(ev-tk-check!
|
||||||
|
"still exactly one seat taken"
|
||||||
|
(ev-booking-count b "gig")
|
||||||
|
1)))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/request-ticket!
|
||||||
|
b
|
||||||
|
"fail"
|
||||||
|
1
|
||||||
|
(quote a)
|
||||||
|
2000
|
||||||
|
"GBP"
|
||||||
|
"ref-a")
|
||||||
|
(ev-tk-check!
|
||||||
|
"seat held before failure"
|
||||||
|
(ev/seats-left b "fail" 1)
|
||||||
|
0)
|
||||||
|
(let
|
||||||
|
((s (ev/settle-payment! b (ev/payment-failed "fail" (quote a) "ref-a"))))
|
||||||
|
(ev-tk-check! "settle failed releases" (get s :status) :released))
|
||||||
|
(ev-tk-check!
|
||||||
|
"released seat frees capacity"
|
||||||
|
(ev/seats-left b "fail" 1)
|
||||||
|
1)
|
||||||
|
(ev-tk-check!
|
||||||
|
"redelivered failure is a noop"
|
||||||
|
(get
|
||||||
|
(ev/settle-payment!
|
||||||
|
b
|
||||||
|
(ev/payment-failed "fail" (quote a) "ref-a"))
|
||||||
|
:status)
|
||||||
|
:noop)
|
||||||
|
(ev-tk-check!
|
||||||
|
"freed seat available to next buyer"
|
||||||
|
(get
|
||||||
|
(ev/request-ticket!
|
||||||
|
b
|
||||||
|
"fail"
|
||||||
|
1
|
||||||
|
(quote c)
|
||||||
|
2000
|
||||||
|
"GBP"
|
||||||
|
"ref-c")
|
||||||
|
:status)
|
||||||
|
:awaiting-payment)
|
||||||
|
(ev/request-ticket!
|
||||||
|
b
|
||||||
|
"exp"
|
||||||
|
1
|
||||||
|
(quote a)
|
||||||
|
2000
|
||||||
|
"GBP"
|
||||||
|
"ref-a")
|
||||||
|
(ev-tk-check!
|
||||||
|
"settle expired releases"
|
||||||
|
(get
|
||||||
|
(ev/settle-payment!
|
||||||
|
b
|
||||||
|
(ev/payment-expired "exp" (quote a) "ref-a"))
|
||||||
|
:status)
|
||||||
|
:released)))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(ev/request-ticket!
|
||||||
|
b
|
||||||
|
"race"
|
||||||
|
1
|
||||||
|
(quote a)
|
||||||
|
2000
|
||||||
|
"GBP"
|
||||||
|
"ref-a")
|
||||||
|
(ev/settle-payment!
|
||||||
|
b
|
||||||
|
(ev/payment-expired "race" (quote a) "ref-a"))
|
||||||
|
(ev-tk-check!
|
||||||
|
"late paid for a vanished hold needs a refund"
|
||||||
|
(get
|
||||||
|
(ev/settle-payment!
|
||||||
|
b
|
||||||
|
(ev/payment-paid "race" (quote a) "ref-a"))
|
||||||
|
:status)
|
||||||
|
:paid-but-no-hold)
|
||||||
|
(ev-tk-check!
|
||||||
|
"no phantom seat created"
|
||||||
|
(ev-booking-count b "race")
|
||||||
|
0)))
|
||||||
|
(let
|
||||||
|
((b (persist/open)))
|
||||||
|
(do
|
||||||
|
(let
|
||||||
|
((start (ev/request-ticket! b "e2e" 3 (quote nia) 2500 "GBP" "ref-nia")))
|
||||||
|
(ev/settle-payment!
|
||||||
|
b
|
||||||
|
(ev/payment-paid
|
||||||
|
(ev/req-occ-key (get start :request))
|
||||||
|
(ev/req-actor (get start :request))
|
||||||
|
(ev/req-ref (get start :request)))))
|
||||||
|
(ev-tk-check!
|
||||||
|
"e2e roster holds the buyer"
|
||||||
|
(ev/roster b "e2e")
|
||||||
|
(list (quote nia)))
|
||||||
|
(ev-tk-check!
|
||||||
|
"e2e seat confirmed"
|
||||||
|
(ev/seat-state b "e2e" (quote nia))
|
||||||
|
:confirmed))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-ticket-tests-run!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(set! ev-tk-pass 0)
|
||||||
|
(set! ev-tk-fail 0)
|
||||||
|
(set! ev-tk-failures (list))
|
||||||
|
(ev-tk-run-all!)
|
||||||
|
{:failures ev-tk-failures :total (+ ev-tk-pass ev-tk-fail) :passed ev-tk-pass :failed ev-tk-fail})))
|
||||||
101
lib/events/ticket.sx
Normal file
101
lib/events/ticket.sx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
;; lib/events/ticket.sx — paid-ticket contract between events and commerce.
|
||||||
|
;;
|
||||||
|
;; A paid booking spans two subsystems. events does NOT import commerce; instead
|
||||||
|
;; this module defines the CONTRACT — the two messages on the wire — and the
|
||||||
|
;; events-side orchestration over provisional holds (booking.sx). commerce
|
||||||
|
;; imports these shapes; the dependency only points one way.
|
||||||
|
;;
|
||||||
|
;; checkout-request events -> commerce "take payment for this seat"
|
||||||
|
;; {:kind :events.checkout :occ-key :actor :amount :currency :ref}
|
||||||
|
;;
|
||||||
|
;; payment-result commerce -> events "here's how payment went"
|
||||||
|
;; {:kind :events.payment :occ-key :actor :ref :status}
|
||||||
|
;; :status ∈ :paid | :failed | :expired
|
||||||
|
;;
|
||||||
|
;; Flow: ev/request-ticket! places a capacity-safe HOLD (reserving the seat so
|
||||||
|
;; it can't be oversold while payment pends) and returns a checkout-request to
|
||||||
|
;; hand to commerce. When commerce reports back, ev/settle-payment! confirms the
|
||||||
|
;; hold on :paid or releases it otherwise. Settlement is idempotent — an
|
||||||
|
;; at-least-once redelivery of the same result is safe. `ref` is the opaque
|
||||||
|
;; correlation/idempotency id; occ-key + actor locate the hold, so settlement
|
||||||
|
;; needs no side table.
|
||||||
|
|
||||||
|
;; ---- contract: checkout request (events -> commerce) ----
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev/checkout-request
|
||||||
|
(fn (occ-key actor amount currency ref) {:actor actor :amount amount :kind :events.checkout :ref ref :currency currency :occ-key occ-key}))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev/checkout-request?
|
||||||
|
(fn (m) (and (dict? m) (= (get m :kind) :events.checkout))))
|
||||||
|
|
||||||
|
(define ev/req-occ-key (fn (r) (get r :occ-key)))
|
||||||
|
(define ev/req-actor (fn (r) (get r :actor)))
|
||||||
|
(define ev/req-amount (fn (r) (get r :amount)))
|
||||||
|
(define ev/req-currency (fn (r) (get r :currency)))
|
||||||
|
(define ev/req-ref (fn (r) (get r :ref)))
|
||||||
|
|
||||||
|
;; ---- contract: payment result (commerce -> events) ----
|
||||||
|
|
||||||
|
(define ev/payment-result (fn (occ-key actor ref status) {:actor actor :kind :events.payment :status status :ref ref :occ-key occ-key}))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev/payment-result?
|
||||||
|
(fn (m) (and (dict? m) (= (get m :kind) :events.payment))))
|
||||||
|
|
||||||
|
(define ev/result-occ-key (fn (r) (get r :occ-key)))
|
||||||
|
(define ev/result-actor (fn (r) (get r :actor)))
|
||||||
|
(define ev/result-ref (fn (r) (get r :ref)))
|
||||||
|
(define ev/result-status (fn (r) (get r :status)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev/payment-paid
|
||||||
|
(fn (occ-key actor ref) (ev/payment-result occ-key actor ref :paid)))
|
||||||
|
(define
|
||||||
|
ev/payment-failed
|
||||||
|
(fn (occ-key actor ref) (ev/payment-result occ-key actor ref :failed)))
|
||||||
|
(define
|
||||||
|
ev/payment-expired
|
||||||
|
(fn (occ-key actor ref) (ev/payment-result occ-key actor ref :expired)))
|
||||||
|
|
||||||
|
;; ---- orchestration ----
|
||||||
|
|
||||||
|
;; Begin a paid booking: place a capacity-safe hold and, if reserved, return a
|
||||||
|
;; checkout-request for commerce. :full when no seat; :already when the actor
|
||||||
|
;; already holds/booked this occurrence (no duplicate request).
|
||||||
|
(define
|
||||||
|
ev/request-ticket!
|
||||||
|
(fn
|
||||||
|
(b occ-key capacity actor amount currency ref)
|
||||||
|
(let
|
||||||
|
((h (ev/hold! b occ-key capacity actor)))
|
||||||
|
(cond
|
||||||
|
((= (get h :status) :held) {:seat (get h :seat) :request (ev/checkout-request occ-key actor amount currency ref) :status :awaiting-payment})
|
||||||
|
((= (get h :status) :already) {:seat (get h :seat) :status :already})
|
||||||
|
(else {:capacity capacity :status :full})))))
|
||||||
|
|
||||||
|
;; Settle a payment result from commerce. :paid confirms the hold; :failed /
|
||||||
|
;; :expired release it. Idempotent: a redelivered :paid stays :confirmed, a
|
||||||
|
;; redelivered release is a :noop. If a :paid arrives for a hold that is already
|
||||||
|
;; gone (released/expired first), returns :paid-but-no-hold so the caller can
|
||||||
|
;; trigger a refund.
|
||||||
|
(define
|
||||||
|
ev/settle-payment!
|
||||||
|
(fn
|
||||||
|
(b result)
|
||||||
|
(let
|
||||||
|
((occ-key (ev/result-occ-key result))
|
||||||
|
(actor (ev/result-actor result))
|
||||||
|
(ref (ev/result-ref result)))
|
||||||
|
(if
|
||||||
|
(= (ev/result-status result) :paid)
|
||||||
|
(let
|
||||||
|
((c (ev/confirm! b occ-key actor)))
|
||||||
|
(cond
|
||||||
|
((= (get c :status) :confirmed) {:actor actor :status :confirmed :ref ref})
|
||||||
|
((= (get c :status) :already-confirmed) {:actor actor :status :confirmed :ref ref})
|
||||||
|
(else {:actor actor :status :paid-but-no-hold :ref ref})))
|
||||||
|
(let
|
||||||
|
((r (ev/release! b occ-key actor)))
|
||||||
|
(if (= (get r :status) :released) {:actor actor :status :released :ref ref} {:actor actor :status :noop :ref ref}))))))
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
|||||||
;; identity/audit.sx — the grant audit ledger.
|
|
||||||
;;
|
|
||||||
;; Every transition that changes a grant — issue, refresh, revoke (and,
|
|
||||||
;; wired from oauth, consent) — appends an immutable event to this
|
|
||||||
;; append-only process. The ledger is queryable by subject, which is what
|
|
||||||
;; `(identity/audit subject)` answers. This is the in-memory realisation
|
|
||||||
;; of the event stream; a persist-backed stream is a later substrate
|
|
||||||
;; concern (Erlang↔persist bridge), kept out of scope here per the loop's
|
|
||||||
;; \"in-memory log until persist lands\" allowance — the queryable
|
|
||||||
;; semantics are identical.
|
|
||||||
;;
|
|
||||||
;; Events are {Seq, Subject, Action}; Seq is a monotonic sequence number.
|
|
||||||
;; Reads return chronological (oldest-first) order:
|
|
||||||
;;
|
|
||||||
;; record(A, Subject, Action) -> ok (one-way; FIFO-ordered)
|
|
||||||
;; audit(A, Subject) -> [{Seq, Subject, Action}, ...]
|
|
||||||
;; actions(A, Subject) -> [Action, ...]
|
|
||||||
;; count(A, Subject) -> N
|
|
||||||
;; all(A) -> [{Seq, Subject, Action}, ...]
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-audit-source
|
|
||||||
"-module(identity_audit).\n\n start() ->\n spawn(fun () -> loop([], 0) end).\n\n record(A, Subject, Action) ->\n A ! {event, Subject, Action},\n ok.\n\n audit(A, Subject) ->\n A ! {audit, Subject, self()},\n receive {audit_reply, R} -> R end.\n\n actions(A, Subject) ->\n A ! {actions, Subject, self()},\n receive {audit_reply, R} -> R end.\n\n count(A, Subject) ->\n A ! {count, Subject, self()},\n receive {audit_reply, R} -> R end.\n\n all(A) ->\n A ! {all, self()},\n receive {audit_reply, R} -> R end.\n\n loop(Events, Seq) ->\n receive\n {event, Subject, Action} ->\n loop([{Seq, Subject, Action} | Events], Seq + 1);\n {audit, Subject, From} ->\n From ! {audit_reply, collect(Subject, Events, [])},\n loop(Events, Seq);\n {actions, Subject, From} ->\n From ! {audit_reply, action_list(Subject, Events, [])},\n loop(Events, Seq);\n {count, Subject, From} ->\n From ! {audit_reply, count_subj(Subject, Events, 0)},\n loop(Events, Seq);\n {all, From} ->\n From ! {audit_reply, reverse(Events, [])},\n loop(Events, Seq);\n {stop, From} ->\n From ! {audit_reply, ok}\n end.\n\n collect(_, [], Acc) -> Acc;\n collect(Subject, [{Seq, S, A} | Rest], Acc) ->\n case S =:= Subject of\n true -> collect(Subject, Rest, [{Seq, S, A} | Acc]);\n false -> collect(Subject, Rest, Acc)\n end.\n\n action_list(_, [], Acc) -> Acc;\n action_list(Subject, [{_, S, A} | Rest], Acc) ->\n case S =:= Subject of\n true -> action_list(Subject, Rest, [A | Acc]);\n false -> action_list(Subject, Rest, Acc)\n end.\n\n count_subj(_, [], N) -> N;\n count_subj(Subject, [{_, S, _} | Rest], N) ->\n case S =:= Subject of\n true -> count_subj(Subject, Rest, N + 1);\n false -> count_subj(Subject, Rest, N)\n end.\n\n reverse([], Acc) -> Acc;\n reverse([H | T], Acc) -> reverse(T, [H | Acc]).")
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-load-audit!
|
|
||||||
(fn () (erlang-load-module identity-audit-source)))
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
;; identity/cache.sx — a delegated grant-verification cache, mirroring the
|
|
||||||
;; Redis-cache pattern apps use in front of grant verification.
|
|
||||||
;;
|
|
||||||
;; The cache is a process wrapping a token registry. introspect() is
|
|
||||||
;; memoised; issue/issue_grant/refresh/revoke pass through. The danger
|
|
||||||
;; with any cache is staleness: a revoked token must NOT keep reading
|
|
||||||
;; valid out of the cache, not even for a millisecond (the loop's hard
|
|
||||||
;; rule). We get that for free with GENERATION invalidation:
|
|
||||||
;;
|
|
||||||
;; - each cache entry records the generation it was written at;
|
|
||||||
;; - a hit requires entry.generation == current generation;
|
|
||||||
;; - any state-changing op that can invalidate an existing token
|
|
||||||
;; (revoke — which cascades to a grant; refresh — whose reuse cascades)
|
|
||||||
;; bumps the generation.
|
|
||||||
;;
|
|
||||||
;; So a single revoke instantly invalidates every cached positive: the
|
|
||||||
;; next introspect is a miss and re-validates against the live registry,
|
|
||||||
;; which returns {inactive}. Revocation stays real; the cache only ever
|
|
||||||
;; accelerates the steady state, never overrides a revocation.
|
|
||||||
;;
|
|
||||||
;; stats() -> {Hits, Misses} so callers can see the cache is live.
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-cache-source
|
|
||||||
"-module(identity_grant_cache).\n\n start() ->\n spawn(fun () ->\n Reg = identity_tokens:start(),\n loop(Reg, 1, [], 0, 0)\n end).\n\n issue(C, Subject, Client, Scope) ->\n C ! {issue, Subject, Client, Scope, self()},\n receive {cache_reply, R} -> R end.\n\n issue_grant(C, Subject, Client, Scope) ->\n C ! {issue_grant, Subject, Client, Scope, self()},\n receive {cache_reply, R} -> R end.\n\n refresh(C, RefreshTok) ->\n C ! {refresh, RefreshTok, self()},\n receive {cache_reply, R} -> R end.\n\n introspect(C, Token) ->\n C ! {introspect, Token, self()},\n receive {cache_reply, R} -> R end.\n\n revoke(C, Token) ->\n C ! {revoke, Token, self()},\n receive {cache_reply, R} -> R end.\n\n stats(C) ->\n C ! {stats, self()},\n receive {cache_reply, R} -> R end.\n\n loop(Reg, Gen, Entries, Hits, Misses) ->\n receive\n {introspect, Tok, From} ->\n case lookup_fresh(Tok, Gen, Entries) of\n {hit, Result} ->\n From ! {cache_reply, Result},\n loop(Reg, Gen, Entries, Hits + 1, Misses);\n miss ->\n Result = identity_tokens:introspect(Reg, Tok),\n From ! {cache_reply, Result},\n loop(Reg, Gen, put_entry(Tok, Result, Gen, Entries), Hits, Misses + 1)\n end;\n {issue, Subject, Client, Scope, From} ->\n From ! {cache_reply, identity_tokens:issue(Reg, Subject, Client, Scope)},\n loop(Reg, Gen, Entries, Hits, Misses);\n {issue_grant, Subject, Client, Scope, From} ->\n From ! {cache_reply, identity_tokens:issue_grant(Reg, Subject, Client, Scope)},\n loop(Reg, Gen, Entries, Hits, Misses);\n {refresh, RTok, From} ->\n From ! {cache_reply, identity_tokens:refresh(Reg, RTok)},\n loop(Reg, Gen + 1, Entries, Hits, Misses);\n {revoke, Tok, From} ->\n identity_tokens:revoke(Reg, Tok),\n From ! {cache_reply, ok},\n loop(Reg, Gen + 1, Entries, Hits, Misses);\n {stats, From} ->\n From ! {cache_reply, {Hits, Misses}},\n loop(Reg, Gen, Entries, Hits, Misses)\n end.\n\n lookup_fresh(_, _, []) -> miss;\n lookup_fresh(Tok, Gen, [{T, {Result, G}} | Rest]) ->\n case T =:= Tok of\n true ->\n case G =:= Gen of\n true -> {hit, Result};\n false -> miss\n end;\n false -> lookup_fresh(Tok, Gen, Rest)\n end.\n\n put_entry(Tok, Result, Gen, Entries) ->\n [{Tok, {Result, Gen}} | remove(Tok, Entries)].\n\n remove(_, []) -> [];\n remove(Tok, [{T, V} | Rest]) ->\n case T =:= Tok of\n true -> remove(Tok, Rest);\n false -> [{T, V} | remove(Tok, Rest)]\n end.")
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-load-cache!
|
|
||||||
(fn () (erlang-load-module identity-cache-source)))
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
;; identity/clients.sx — the OAuth client registry (RFC 6749 §2).
|
|
||||||
;;
|
|
||||||
;; A client is registered with a type, a secret, and its allow-listed
|
|
||||||
;; redirect_uris:
|
|
||||||
;;
|
|
||||||
;; public — cannot keep a secret (SPAs, native apps, §2.1);
|
|
||||||
;; identified but not authenticated.
|
|
||||||
;; confidential — can authenticate; MUST present its secret at the token
|
|
||||||
;; endpoint (§3.2.1, §4.1.3). A wrong secret is
|
|
||||||
;; invalid_client — never a soft pass.
|
|
||||||
;;
|
|
||||||
;; Redirect URIs must be pre-registered (§3.1.2.2 + OAuth Security BCP):
|
|
||||||
;; valid_redirect/3 is the exact-match check the authorize/exchange steps
|
|
||||||
;; consult so an attacker cannot redirect the code to an unregistered URI.
|
|
||||||
;;
|
|
||||||
;; register(C, ClientId, Type, Secret, RedirectUris) -> ok | {error, exists}
|
|
||||||
;; lookup(C, ClientId) -> {ok, Type, RedirectUris} | {error, unknown_client}
|
|
||||||
;; authenticate(C, ClientId, Sec) -> {ok, public} | {ok, confidential}
|
|
||||||
;; | {error, invalid_client} | {error, unknown_client}
|
|
||||||
;; valid_redirect(C, ClientId, U) -> true | false
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-clients-source
|
|
||||||
"-module(identity_clients).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n register(C, ClientId, Type, Secret, RedirectUris) ->\n C ! {register, ClientId, Type, Secret, RedirectUris, self()},\n receive {client_reply, R} -> R end.\n\n lookup(C, ClientId) ->\n C ! {lookup, ClientId, self()},\n receive {client_reply, R} -> R end.\n\n authenticate(C, ClientId, Secret) ->\n C ! {authenticate, ClientId, Secret, self()},\n receive {client_reply, R} -> R end.\n\n valid_redirect(C, ClientId, Uri) ->\n C ! {valid_redirect, ClientId, Uri, self()},\n receive {client_reply, R} -> R end.\n\n loop(Clients) ->\n receive\n {register, ClientId, Type, Secret, RedirectUris, From} ->\n case find(ClientId, Clients) of\n {ok, _} ->\n From ! {client_reply, {error, exists}},\n loop(Clients);\n none ->\n From ! {client_reply, ok},\n loop([{ClientId, {Type, Secret, RedirectUris}} | Clients])\n end;\n {lookup, ClientId, From} ->\n case find(ClientId, Clients) of\n none -> From ! {client_reply, {error, unknown_client}};\n {ok, {Type, _, Uris}} -> From ! {client_reply, {ok, Type, Uris}}\n end,\n loop(Clients);\n {authenticate, ClientId, Secret, From} ->\n case find(ClientId, Clients) of\n none ->\n From ! {client_reply, {error, unknown_client}};\n {ok, {public, _, _}} ->\n From ! {client_reply, {ok, public}};\n {ok, {confidential, S, _}} ->\n case S =:= Secret of\n true -> From ! {client_reply, {ok, confidential}};\n false -> From ! {client_reply, {error, invalid_client}}\n end\n end,\n loop(Clients);\n {valid_redirect, ClientId, Uri, From} ->\n case find(ClientId, Clients) of\n none -> From ! {client_reply, false};\n {ok, {_, _, Uris}} -> From ! {client_reply, member(Uri, Uris)}\n end,\n loop(Clients);\n {stop, From} ->\n From ! {client_reply, ok}\n end.\n\n member(_, []) -> false;\n member(X, [Y | Rest]) ->\n case X =:= Y of\n true -> true;\n false -> member(X, Rest)\n end.\n\n find(_, []) -> none;\n find(Key, [{K, V} | Rest]) ->\n case K =:= Key of\n true -> {ok, V};\n false -> find(Key, Rest)\n end.")
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-load-clients!
|
|
||||||
(fn () (erlang-load-module identity-clients-source)))
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# identity-on-sx conformance runner.
|
|
||||||
#
|
|
||||||
# Loads the Erlang-on-SX substrate, the identity library, and every
|
|
||||||
# identity test suite via the epoch protocol, collects pass/fail counts,
|
|
||||||
# and writes lib/identity/scoreboard.json + .md.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# bash lib/identity/conformance.sh # run all suites
|
|
||||||
# bash lib/identity/conformance.sh -v # verbose per-suite
|
|
||||||
|
|
||||||
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:-}"
|
|
||||||
TMPFILE=$(mktemp)
|
|
||||||
OUTFILE=$(mktemp)
|
|
||||||
trap "rm -f $TMPFILE $OUTFILE" EXIT
|
|
||||||
|
|
||||||
# Each suite: name | counter pass | counter total
|
|
||||||
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"
|
|
||||||
"api|id-api-test-pass|id-api-test-count"
|
|
||||||
"oauth|id-oauth-test-pass|id-oauth-test-count"
|
|
||||||
"sso|id-sso-test-pass|id-sso-test-count"
|
|
||||||
"membership|id-membership-test-pass|id-membership-test-count"
|
|
||||||
"cache|id-cache-test-pass|id-cache-test-count"
|
|
||||||
"audit|id-audit-test-pass|id-audit-test-count"
|
|
||||||
"federation|id-fed-test-pass|id-fed-test-count"
|
|
||||||
"expiry|id-expiry-test-pass|id-expiry-test-count"
|
|
||||||
"clients|id-clients-test-pass|id-clients-test-count"
|
|
||||||
"grants|id-grants-test-pass|id-grants-test-count"
|
|
||||||
"device|id-device-test-pass|id-device-test-count"
|
|
||||||
"facade|id-facade-test-pass|id-facade-test-count"
|
|
||||||
"delegation|id-deleg-test-pass|id-deleg-test-count"
|
|
||||||
"session-mgmt|id-smgmt-test-pass|id-smgmt-test-count"
|
|
||||||
"exchange|id-xchg-test-pass|id-xchg-test-count"
|
|
||||||
"introspect|id-intr-test-pass|id-intr-test-count"
|
|
||||||
"par|id-par-test-pass|id-par-test-count"
|
|
||||||
"dynreg|id-dyn-test-pass|id-dyn-test-count"
|
|
||||||
"account|id-acct-test-pass|id-acct-test-count"
|
|
||||||
)
|
|
||||||
|
|
||||||
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/identity/session.sx")
|
|
||||||
(load "lib/identity/token.sx")
|
|
||||||
(load "lib/identity/registry.sx")
|
|
||||||
(load "lib/identity/api.sx")
|
|
||||||
(load "lib/identity/oauth.sx")
|
|
||||||
(load "lib/identity/membership.sx")
|
|
||||||
(load "lib/identity/cache.sx")
|
|
||||||
(load "lib/identity/audit.sx")
|
|
||||||
(load "lib/identity/federation.sx")
|
|
||||||
(load "lib/identity/clients.sx")
|
|
||||||
(load "lib/identity/device.sx")
|
|
||||||
(load "lib/identity/delegation.sx")
|
|
||||||
(load "lib/identity/tests/session.sx")
|
|
||||||
(load "lib/identity/tests/token.sx")
|
|
||||||
(load "lib/identity/tests/registry.sx")
|
|
||||||
(load "lib/identity/tests/api.sx")
|
|
||||||
(load "lib/identity/tests/oauth.sx")
|
|
||||||
(load "lib/identity/tests/sso.sx")
|
|
||||||
(load "lib/identity/tests/membership.sx")
|
|
||||||
(load "lib/identity/tests/cache.sx")
|
|
||||||
(load "lib/identity/tests/audit.sx")
|
|
||||||
(load "lib/identity/tests/federation.sx")
|
|
||||||
(load "lib/identity/tests/expiry.sx")
|
|
||||||
(load "lib/identity/tests/clients.sx")
|
|
||||||
(load "lib/identity/tests/grants.sx")
|
|
||||||
(load "lib/identity/tests/device.sx")
|
|
||||||
(load "lib/identity/tests/facade.sx")
|
|
||||||
(load "lib/identity/tests/delegation.sx")
|
|
||||||
(load "lib/identity/tests/session_mgmt.sx")
|
|
||||||
(load "lib/identity/tests/exchange.sx")
|
|
||||||
(load "lib/identity/tests/introspect.sx")
|
|
||||||
(load "lib/identity/tests/par.sx")
|
|
||||||
(load "lib/identity/tests/dynreg.sx")
|
|
||||||
(load "lib/identity/tests/account.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)")
|
|
||||||
(epoch 103)
|
|
||||||
(eval "(list id-api-test-pass id-api-test-count)")
|
|
||||||
(epoch 104)
|
|
||||||
(eval "(list id-oauth-test-pass id-oauth-test-count)")
|
|
||||||
(epoch 105)
|
|
||||||
(eval "(list id-sso-test-pass id-sso-test-count)")
|
|
||||||
(epoch 106)
|
|
||||||
(eval "(list id-membership-test-pass id-membership-test-count)")
|
|
||||||
(epoch 107)
|
|
||||||
(eval "(list id-cache-test-pass id-cache-test-count)")
|
|
||||||
(epoch 108)
|
|
||||||
(eval "(list id-audit-test-pass id-audit-test-count)")
|
|
||||||
(epoch 109)
|
|
||||||
(eval "(list id-fed-test-pass id-fed-test-count)")
|
|
||||||
(epoch 110)
|
|
||||||
(eval "(list id-expiry-test-pass id-expiry-test-count)")
|
|
||||||
(epoch 111)
|
|
||||||
(eval "(list id-clients-test-pass id-clients-test-count)")
|
|
||||||
(epoch 112)
|
|
||||||
(eval "(list id-grants-test-pass id-grants-test-count)")
|
|
||||||
(epoch 113)
|
|
||||||
(eval "(list id-device-test-pass id-device-test-count)")
|
|
||||||
(epoch 114)
|
|
||||||
(eval "(list id-facade-test-pass id-facade-test-count)")
|
|
||||||
(epoch 115)
|
|
||||||
(eval "(list id-deleg-test-pass id-deleg-test-count)")
|
|
||||||
(epoch 116)
|
|
||||||
(eval "(list id-smgmt-test-pass id-smgmt-test-count)")
|
|
||||||
(epoch 117)
|
|
||||||
(eval "(list id-xchg-test-pass id-xchg-test-count)")
|
|
||||||
(epoch 118)
|
|
||||||
(eval "(list id-intr-test-pass id-intr-test-count)")
|
|
||||||
(epoch 119)
|
|
||||||
(eval "(list id-par-test-pass id-par-test-count)")
|
|
||||||
(epoch 120)
|
|
||||||
(eval "(list id-dyn-test-pass id-dyn-test-count)")
|
|
||||||
(epoch 121)
|
|
||||||
(eval "(list id-acct-test-pass id-acct-test-count)")
|
|
||||||
EPOCHS
|
|
||||||
|
|
||||||
timeout 1200 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
|
||||||
|
|
||||||
parse_pair() {
|
|
||||||
local epoch="$1"
|
|
||||||
local line
|
|
||||||
line=$(grep -A1 "^(ok-len $epoch " "$OUTFILE" | tail -1)
|
|
||||||
echo "$line" | sed -E 's/[()]//g'
|
|
||||||
}
|
|
||||||
|
|
||||||
TOTAL_PASS=0
|
|
||||||
TOTAL_COUNT=0
|
|
||||||
JSON_SUITES=""
|
|
||||||
MD_ROWS=""
|
|
||||||
|
|
||||||
idx=0
|
|
||||||
for entry in "${SUITES[@]}"; do
|
|
||||||
name="${entry%%|*}"
|
|
||||||
epoch=$((100 + idx))
|
|
||||||
pair=$(parse_pair "$epoch")
|
|
||||||
pass=$(echo "$pair" | awk '{print $1}')
|
|
||||||
count=$(echo "$pair" | awk '{print $2}')
|
|
||||||
if [ -z "$pass" ] || [ -z "$count" ]; then
|
|
||||||
pass=0
|
|
||||||
count=0
|
|
||||||
fi
|
|
||||||
TOTAL_PASS=$((TOTAL_PASS + pass))
|
|
||||||
TOTAL_COUNT=$((TOTAL_COUNT + count))
|
|
||||||
status="ok"
|
|
||||||
marker="✅"
|
|
||||||
if [ "$pass" != "$count" ]; then
|
|
||||||
status="fail"
|
|
||||||
marker="❌"
|
|
||||||
fi
|
|
||||||
if [ "$VERBOSE" = "-v" ]; then
|
|
||||||
printf " %-12s %s/%s\n" "$name" "$pass" "$count"
|
|
||||||
fi
|
|
||||||
if [ -n "$JSON_SUITES" ]; then JSON_SUITES+=","; fi
|
|
||||||
JSON_SUITES+=$'\n '
|
|
||||||
JSON_SUITES+="{\"name\":\"$name\",\"pass\":$pass,\"total\":$count,\"status\":\"$status\"}"
|
|
||||||
MD_ROWS+="| $marker | $name | $pass | $count |"$'\n'
|
|
||||||
idx=$((idx + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
printf '\nidentity-on-sx conformance: %d / %d\n' "$TOTAL_PASS" "$TOTAL_COUNT"
|
|
||||||
|
|
||||||
cat > lib/identity/scoreboard.json <<JSON
|
|
||||||
{
|
|
||||||
"language": "identity",
|
|
||||||
"total_pass": $TOTAL_PASS,
|
|
||||||
"total": $TOTAL_COUNT,
|
|
||||||
"suites": [$JSON_SUITES
|
|
||||||
]
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
cat > lib/identity/scoreboard.md <<MD
|
|
||||||
# identity-on-sx Scoreboard
|
|
||||||
|
|
||||||
**Total: ${TOTAL_PASS} / ${TOTAL_COUNT} tests passing**
|
|
||||||
|
|
||||||
| | Suite | Pass | Total |
|
|
||||||
|---|---|---|---|
|
|
||||||
$MD_ROWS
|
|
||||||
|
|
||||||
Generated by \`lib/identity/conformance.sh\`.
|
|
||||||
MD
|
|
||||||
|
|
||||||
if [ "$TOTAL_PASS" -eq "$TOTAL_COUNT" ]; then
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
;; identity/delegation.sx — the identity -> acl delegation boundary.
|
|
||||||
;;
|
|
||||||
;; This is the loop's central architectural rule made concrete:
|
|
||||||
;; AUTHENTICATION is identity's job; AUTHORIZATION is acl's. A request is
|
|
||||||
;; checked in two stages, and the order matters:
|
|
||||||
;;
|
|
||||||
;; 1. identity proves WHO via the opaque token (introspect). If the token
|
|
||||||
;; is inactive, the answer is {error, unauthenticated} — a 401. acl is
|
|
||||||
;; NEVER consulted; \"I don't know who you are\" is not a permission
|
|
||||||
;; question.
|
|
||||||
;; 2. only for an authenticated subject does identity construct the
|
|
||||||
;; permission query {Subject, Scope, Action, Resource} and HAND IT OFF
|
|
||||||
;; to acl. acl returns permit | deny; deny is {error, forbidden} — a
|
|
||||||
;; 403. identity itself never decides permission.
|
|
||||||
;;
|
|
||||||
;; The real decider is acl-on-sx (Datalog), which runs as a different
|
|
||||||
;; guest language on SX and is wired in at the integration layer. Here the
|
|
||||||
;; acl side is a labelled STUB process so the boundary is exercised: it
|
|
||||||
;; permits when the Action is within the token's granted Scope. Swap the
|
|
||||||
;; stub pid for the acl adapter and the boundary is unchanged.
|
|
||||||
;;
|
|
||||||
;; check(TokReg, Acl, Token, Action, Resource) ->
|
|
||||||
;; {ok, Subject} | {error, unauthenticated} | {error, forbidden}
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-delegation-source
|
|
||||||
"-module(identity_delegation).\n\n check(TokReg, Acl, Token, Action, Resource) ->\n case identity_tokens:introspect(TokReg, Token) of\n {inactive} ->\n {error, unauthenticated};\n {active, Subject, _Client, Scope} ->\n Acl ! {acl_query, Subject, Scope, Action, Resource, self()},\n receive {acl_verdict, V} ->\n case V of\n permit -> {ok, Subject};\n deny -> {error, forbidden}\n end\n end\n end.\n\n %% --- stub acl decider (stands in for acl-on-sx / Datalog) ---\n %% Permits iff the Action is one of the token's granted scopes. The real\n %% acl decides on rules + facts; this only exercises the handoff shape.\n stub_acl() ->\n spawn(fun () -> acl_loop() end).\n\n acl_loop() ->\n receive\n {acl_query, _Subject, Scope, Action, _Resource, From} ->\n From ! {acl_verdict, decide(Action, Scope)},\n acl_loop();\n stop ->\n ok\n end.\n\n decide(Action, Scope) ->\n case member(Action, Scope) of\n true -> permit;\n false -> deny\n end.\n\n member(_, []) -> false;\n member(X, [Y | Rest]) ->\n case X =:= Y of\n true -> true;\n false -> member(X, Rest)\n end.")
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-load-delegation!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(identity-load-token!)
|
|
||||||
(erlang-load-module identity-delegation-source)))
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
;; identity/device.sx — the device authorization grant (RFC 8628).
|
|
||||||
;;
|
|
||||||
;; For input-constrained devices (TVs, CLIs): the device gets a device_code
|
|
||||||
;; + user_code, the user approves out-of-band on another device, and the
|
|
||||||
;; device polls the token endpoint until it flips. The poll status machine
|
|
||||||
;; is RFC 8628 §3.5:
|
|
||||||
;;
|
|
||||||
;; authorize(ClientId, Scope) -> {ok, DeviceCode, UserCode}
|
|
||||||
;; approve(UserCode, Subject) -> ok | {error, ...} (the human's browser)
|
|
||||||
;; deny(UserCode) -> ok | {error, ...}
|
|
||||||
;; poll(DeviceCode) ->
|
|
||||||
;; pending -> {error, authorization_pending}
|
|
||||||
;; denied -> {error, access_denied}
|
|
||||||
;; approved -> {ok, Token} (device code is then single-use)
|
|
||||||
;; consumed -> {error, invalid_grant}
|
|
||||||
;; unknown -> {error, invalid_grant}
|
|
||||||
;;
|
|
||||||
;; Tokens are grant-backed (token.sx) so revocation stays real. Device-code
|
|
||||||
;; expiry and slow_down (poll-rate limiting) are deferred — the substrate
|
|
||||||
;; has no wall clock and the core status machine is the security-relevant
|
|
||||||
;; part; introspect via token.sx already honours token TTL.
|
|
||||||
;;
|
|
||||||
;; State: loop(TokReg, Requests) where Requests is
|
|
||||||
;; [{DeviceCode, UserCode, ClientId, Scope, Status}]
|
|
||||||
;; Status :: pending | {approved, Subject} | denied | consumed
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-device-source
|
|
||||||
"-module(identity_device).\n\n start() ->\n spawn(fun () ->\n TokReg = identity_tokens:start(),\n loop(TokReg, [])\n end).\n\n authorize(D, ClientId, Scope) ->\n D ! {authorize, ClientId, Scope, self()},\n receive {device_reply, R} -> R end.\n\n approve(D, UserCode, Subject) ->\n D ! {approve, UserCode, Subject, self()},\n receive {device_reply, R} -> R end.\n\n deny(D, UserCode) ->\n D ! {deny, UserCode, self()},\n receive {device_reply, R} -> R end.\n\n poll(D, DeviceCode) ->\n D ! {poll, DeviceCode, self()},\n receive {device_reply, R} -> R end.\n\n introspect(D, Token) ->\n D ! {introspect, Token, self()},\n receive {device_reply, R} -> R end.\n\n loop(TokReg, Requests) ->\n receive\n {authorize, ClientId, Scope, From} ->\n DeviceCode = make_ref(),\n UserCode = make_ref(),\n From ! {device_reply, {ok, DeviceCode, UserCode}},\n loop(TokReg, [{DeviceCode, UserCode, ClientId, Scope, pending} | Requests]);\n {approve, UserCode, Subject, From} ->\n case find_user(UserCode, Requests) of\n none ->\n From ! {device_reply, {error, unknown_code}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, pending}} ->\n From ! {device_reply, ok},\n loop(TokReg, set_user(UserCode, {approved, Subject}, Requests));\n {ok, {_, _, _, _, St}} ->\n From ! {device_reply, {error, St}},\n loop(TokReg, Requests)\n end;\n {deny, UserCode, From} ->\n case find_user(UserCode, Requests) of\n none ->\n From ! {device_reply, {error, unknown_code}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, pending}} ->\n From ! {device_reply, ok},\n loop(TokReg, set_user(UserCode, denied, Requests));\n {ok, {_, _, _, _, St}} ->\n From ! {device_reply, {error, St}},\n loop(TokReg, Requests)\n end;\n {poll, DeviceCode, From} ->\n case find_device(DeviceCode, Requests) of\n none ->\n From ! {device_reply, {error, invalid_grant}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, pending}} ->\n From ! {device_reply, {error, authorization_pending}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, denied}} ->\n From ! {device_reply, {error, access_denied}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, consumed}} ->\n From ! {device_reply, {error, invalid_grant}},\n loop(TokReg, Requests);\n {ok, {_, _, ClientId, Scope, {approved, Subject}}} ->\n {ok, Token} = identity_tokens:issue(TokReg, Subject, ClientId, Scope),\n From ! {device_reply, {ok, Token}},\n loop(TokReg, set_device(DeviceCode, consumed, Requests))\n end;\n {introspect, Token, From} ->\n From ! {device_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, Requests);\n {stop, From} ->\n From ! {device_reply, ok}\n end.\n\n find_device(_, []) -> none;\n find_device(DCode, [{D, U, C, S, St} | Rest]) ->\n case D =:= DCode of\n true -> {ok, {D, U, C, S, St}};\n false -> find_device(DCode, Rest)\n end.\n\n find_user(_, []) -> none;\n find_user(UCode, [{D, U, C, S, St} | Rest]) ->\n case U =:= UCode of\n true -> {ok, {D, U, C, S, St}};\n false -> find_user(UCode, Rest)\n end.\n\n set_device(_, _, []) -> [];\n set_device(DCode, NewSt, [{D, U, C, S, St} | Rest]) ->\n case D =:= DCode of\n true -> [{D, U, C, S, NewSt} | Rest];\n false -> [{D, U, C, S, St} | set_device(DCode, NewSt, Rest)]\n end.\n\n set_user(_, _, []) -> [];\n set_user(UCode, NewSt, [{D, U, C, S, St} | Rest]) ->\n case U =:= UCode of\n true -> [{D, U, C, S, NewSt} | Rest];\n false -> [{D, U, C, S, St} | set_user(UCode, NewSt, Rest)]\n end.")
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-load-device!
|
|
||||||
(fn () (identity-load-token!) (erlang-load-module identity-device-source)))
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
;; identity/federation.sx — federated identity: peer-asserted subjects,
|
|
||||||
;; advisory and trust-gated.
|
|
||||||
;;
|
|
||||||
;; A peer instance can assert \"this remote subject authenticated with me\".
|
|
||||||
;; We accept such an assertion ONLY from a peer we explicitly trust
|
|
||||||
;; (trust-gated); an assertion from an unknown peer is {error, untrusted},
|
|
||||||
;; never silently honoured. Even when accepted, the resulting identity is
|
|
||||||
;; ADVISORY: it is flagged peer_asserted with its origin peer, never
|
|
||||||
;; promoted to local authority. Downstream (acl) decides how much a
|
|
||||||
;; peer-asserted identity may do; identity only records who asserted it.
|
|
||||||
;;
|
|
||||||
;; Cross-instance subject mapping turns a (Peer, RemoteSubject) pair into a
|
|
||||||
;; stable local subject. By default it is namespaced — {federated, Peer,
|
|
||||||
;; RemoteSubject} — so two peers' \"alice\" never collide; an explicit map
|
|
||||||
;; can alias a remote subject to a local one.
|
|
||||||
;;
|
|
||||||
;; trust(F, Peer) / untrust(F, Peer) / trusted(F, Peer)
|
|
||||||
;; map(F, Peer, Remote, Local) -> ok (optional alias)
|
|
||||||
;; resolve(F, Peer, Remote) -> {ok, LocalSubject}
|
|
||||||
;; assert_id(F, Peer, Remote) -> {ok, LocalSubject}
|
|
||||||
;; | {error, untrusted}
|
|
||||||
;; provenance(F, LocalSubject) -> {peer_asserted, Peer} | {local}
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-federation-source
|
|
||||||
"-module(identity_federation).\n\n start() ->\n spawn(fun () -> loop([], [], []) end).\n\n trust(F, Peer) ->\n F ! {trust, Peer, self()},\n receive {fed_reply, R} -> R end.\n\n untrust(F, Peer) ->\n F ! {untrust, Peer, self()},\n receive {fed_reply, R} -> R end.\n\n trusted(F, Peer) ->\n F ! {trusted, Peer, self()},\n receive {fed_reply, R} -> R end.\n\n map(F, Peer, Remote, Local) ->\n F ! {map, Peer, Remote, Local, self()},\n receive {fed_reply, R} -> R end.\n\n resolve(F, Peer, Remote) ->\n F ! {resolve, Peer, Remote, self()},\n receive {fed_reply, R} -> R end.\n\n assert_id(F, Peer, Remote) ->\n F ! {assert_id, Peer, Remote, self()},\n receive {fed_reply, R} -> R end.\n\n provenance(F, Local) ->\n F ! {provenance, Local, self()},\n receive {fed_reply, R} -> R end.\n\n loop(Trusted, Maps, Asserted) ->\n receive\n {trust, Peer, From} ->\n From ! {fed_reply, ok},\n loop(add_unique(Peer, Trusted), Maps, Asserted);\n {untrust, Peer, From} ->\n From ! {fed_reply, ok},\n loop(drop(Peer, Trusted), Maps, Asserted);\n {trusted, Peer, From} ->\n From ! {fed_reply, member(Peer, Trusted)},\n loop(Trusted, Maps, Asserted);\n {map, Peer, Remote, Local, From} ->\n From ! {fed_reply, ok},\n loop(Trusted, [{{Peer, Remote}, Local} | drop_map(Peer, Remote, Maps)], Asserted);\n {resolve, Peer, Remote, From} ->\n From ! {fed_reply, {ok, resolve_local(Peer, Remote, Maps)}},\n loop(Trusted, Maps, Asserted);\n {assert_id, Peer, Remote, From} ->\n case member(Peer, Trusted) of\n false ->\n From ! {fed_reply, {error, untrusted}},\n loop(Trusted, Maps, Asserted);\n true ->\n Local = resolve_local(Peer, Remote, Maps),\n From ! {fed_reply, {ok, Local}},\n loop(Trusted, Maps, [{Local, Peer} | drop_assert(Local, Asserted)])\n end;\n {provenance, Local, From} ->\n case find_assert(Local, Asserted) of\n {ok, Peer} -> From ! {fed_reply, {peer_asserted, Peer}};\n none -> From ! {fed_reply, {local}}\n end,\n loop(Trusted, Maps, Asserted);\n {stop, From} ->\n From ! {fed_reply, ok}\n end.\n\n resolve_local(Peer, Remote, Maps) ->\n case find_map(Peer, Remote, Maps) of\n {ok, Local} -> Local;\n none -> {federated, Peer, Remote}\n end.\n\n find_map(_, _, []) -> none;\n find_map(Peer, Remote, [{{P, R}, Local} | Rest]) ->\n case same(P, Peer, R, Remote) of\n true -> {ok, Local};\n false -> find_map(Peer, Remote, Rest)\n end.\n\n drop_map(_, _, []) -> [];\n drop_map(Peer, Remote, [{{P, R}, Local} | Rest]) ->\n case same(P, Peer, R, Remote) of\n true -> drop_map(Peer, Remote, Rest);\n false -> [{{P, R}, Local} | drop_map(Peer, Remote, Rest)]\n end.\n\n same(P, Peer, R, Remote) ->\n case P =:= Peer of\n true -> R =:= Remote;\n false -> false\n end.\n\n find_assert(_, []) -> none;\n find_assert(Local, [{L, Peer} | Rest]) ->\n case L =:= Local of\n true -> {ok, Peer};\n false -> find_assert(Local, Rest)\n end.\n\n drop_assert(_, []) -> [];\n drop_assert(Local, [{L, Peer} | Rest]) ->\n case L =:= Local of\n true -> drop_assert(Local, Rest);\n false -> [{L, Peer} | drop_assert(Local, Rest)]\n end.\n\n add_unique(X, Xs) ->\n case member(X, Xs) of\n true -> Xs;\n false -> [X | Xs]\n end.\n\n drop(_, []) -> [];\n drop(X, [Y | Rest]) ->\n case X =:= Y of\n true -> drop(X, Rest);\n false -> [Y | drop(X, Rest)]\n end.\n\n member(_, []) -> false;\n member(X, [Y | Rest]) ->\n case X =:= Y of\n true -> true;\n false -> member(X, Rest)\n end.")
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-load-federation!
|
|
||||||
(fn () (erlang-load-module identity-federation-source)))
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
;; identity/membership.sx — coop membership state + per-app projection.
|
|
||||||
;;
|
|
||||||
;; Membership is canonical subject state held by one process, a guarded
|
|
||||||
;; state machine (invalid transitions are explicit errors, never silent
|
|
||||||
;; no-ops):
|
|
||||||
;;
|
|
||||||
;; none --request--> pending --approve--> active
|
|
||||||
;; active --lapse--> lapsed --reinstate--> active
|
|
||||||
;; {pending|active|lapsed} --revoke--> revoked (terminal)
|
|
||||||
;;
|
|
||||||
;; A per-app GRANT PROJECTION renders that one canonical state into the
|
|
||||||
;; view a given client app consumes — mirroring rose-ash's per-app grant
|
|
||||||
;; verification. The projection is pure identity: it reports WHAT the
|
|
||||||
;; subject's membership is for that app; it does NOT decide whether the
|
|
||||||
;; app should let them in. That permission question is acl's, keyed off
|
|
||||||
;; this projection.
|
|
||||||
;;
|
|
||||||
;; project(Subject, App) ->
|
|
||||||
;; active -> {member, Tier, App}
|
|
||||||
;; pending -> {pending, App}
|
|
||||||
;; lapsed -> {lapsed, App}
|
|
||||||
;; revoked -> {denied, App}
|
|
||||||
;; none -> {non_member, App}
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-membership-source
|
|
||||||
"-module(identity_membership).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n request(M, Subject, Tier) ->\n M ! {request, Subject, Tier, self()},\n receive {membership_reply, R} -> R end.\n\n approve(M, Subject) ->\n M ! {approve, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n lapse(M, Subject) ->\n M ! {lapse, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n reinstate(M, Subject) ->\n M ! {reinstate, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n revoke(M, Subject) ->\n M ! {revoke, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n status(M, Subject) ->\n M ! {status, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n project(M, Subject, App) ->\n M ! {project, Subject, App, self()},\n receive {membership_reply, R} -> R end.\n\n loop(Members) ->\n receive\n {request, Subject, Tier, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, ok},\n loop([{Subject, {pending, Tier}} | Members]);\n {ok, _} ->\n From ! {membership_reply, {error, exists}},\n loop(Members)\n end;\n {approve, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {pending, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {active, Tier}, Members));\n {ok, {St, _}} ->\n From ! {membership_reply, {error, St}},\n loop(Members)\n end;\n {lapse, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {active, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {lapsed, Tier}, Members));\n {ok, {St, _}} ->\n From ! {membership_reply, {error, St}},\n loop(Members)\n end;\n {reinstate, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {lapsed, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {active, Tier}, Members));\n {ok, {St, _}} ->\n From ! {membership_reply, {error, St}},\n loop(Members)\n end;\n {revoke, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {_, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {revoked, Tier}, Members))\n end;\n {status, Subject, From} ->\n case find(Subject, Members) of\n none -> From ! {membership_reply, {none}};\n {ok, {St, Tier}} -> From ! {membership_reply, {ok, St, Tier}}\n end,\n loop(Members);\n {project, Subject, App, From} ->\n From ! {membership_reply, project_view(Subject, App, Members)},\n loop(Members);\n {stop, From} ->\n From ! {membership_reply, ok}\n end.\n\n project_view(Subject, App, Members) ->\n case find(Subject, Members) of\n none -> {non_member, App};\n {ok, {active, Tier}} -> {member, Tier, App};\n {ok, {pending, _}} -> {pending, App};\n {ok, {lapsed, _}} -> {lapsed, App};\n {ok, {revoked, _}} -> {denied, App}\n end.\n\n set_record(_, _, []) -> [];\n set_record(Subject, Rec, [{S, Old} | Rest]) ->\n case S =:= Subject of\n true -> [{S, Rec} | Rest];\n false -> [{S, Old} | set_record(Subject, Rec, Rest)]\n end.\n\n find(_, []) -> none;\n find(Key, [{K, V} | Rest]) ->\n case K =:= Key of\n true -> {ok, V};\n false -> find(Key, Rest)\n end.")
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-load-membership!
|
|
||||||
(fn () (erlang-load-module identity-membership-source)))
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,22 +0,0 @@
|
|||||||
;; 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)))
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"language": "identity",
|
|
||||||
"total_pass": 229,
|
|
||||||
"total": 229,
|
|
||||||
"suites": [
|
|
||||||
{"name":"session","pass":11,"total":11,"status":"ok"},
|
|
||||||
{"name":"token","pass":24,"total":24,"status":"ok"},
|
|
||||||
{"name":"registry","pass":9,"total":9,"status":"ok"},
|
|
||||||
{"name":"api","pass":10,"total":10,"status":"ok"},
|
|
||||||
{"name":"oauth","pass":17,"total":17,"status":"ok"},
|
|
||||||
{"name":"sso","pass":10,"total":10,"status":"ok"},
|
|
||||||
{"name":"membership","pass":17,"total":17,"status":"ok"},
|
|
||||||
{"name":"cache","pass":9,"total":9,"status":"ok"},
|
|
||||||
{"name":"audit","pass":11,"total":11,"status":"ok"},
|
|
||||||
{"name":"federation","pass":12,"total":12,"status":"ok"},
|
|
||||||
{"name":"expiry","pass":8,"total":8,"status":"ok"},
|
|
||||||
{"name":"clients","pass":11,"total":11,"status":"ok"},
|
|
||||||
{"name":"grants","pass":9,"total":9,"status":"ok"},
|
|
||||||
{"name":"device","pass":10,"total":10,"status":"ok"},
|
|
||||||
{"name":"facade","pass":9,"total":9,"status":"ok"},
|
|
||||||
{"name":"delegation","pass":8,"total":8,"status":"ok"},
|
|
||||||
{"name":"session-mgmt","pass":8,"total":8,"status":"ok"},
|
|
||||||
{"name":"exchange","pass":8,"total":8,"status":"ok"},
|
|
||||||
{"name":"introspect","pass":9,"total":9,"status":"ok"},
|
|
||||||
{"name":"par","pass":7,"total":7,"status":"ok"},
|
|
||||||
{"name":"dynreg","pass":5,"total":5,"status":"ok"},
|
|
||||||
{"name":"account","pass":7,"total":7,"status":"ok"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# identity-on-sx Scoreboard
|
|
||||||
|
|
||||||
**Total: 229 / 229 tests passing**
|
|
||||||
|
|
||||||
| | Suite | Pass | Total |
|
|
||||||
|---|---|---|---|
|
|
||||||
| ✅ | session | 11 | 11 |
|
|
||||||
| ✅ | token | 24 | 24 |
|
|
||||||
| ✅ | registry | 9 | 9 |
|
|
||||||
| ✅ | api | 10 | 10 |
|
|
||||||
| ✅ | oauth | 17 | 17 |
|
|
||||||
| ✅ | sso | 10 | 10 |
|
|
||||||
| ✅ | membership | 17 | 17 |
|
|
||||||
| ✅ | cache | 9 | 9 |
|
|
||||||
| ✅ | audit | 11 | 11 |
|
|
||||||
| ✅ | federation | 12 | 12 |
|
|
||||||
| ✅ | expiry | 8 | 8 |
|
|
||||||
| ✅ | clients | 11 | 11 |
|
|
||||||
| ✅ | grants | 9 | 9 |
|
|
||||||
| ✅ | device | 10 | 10 |
|
|
||||||
| ✅ | facade | 9 | 9 |
|
|
||||||
| ✅ | delegation | 8 | 8 |
|
|
||||||
| ✅ | session-mgmt | 8 | 8 |
|
|
||||||
| ✅ | exchange | 8 | 8 |
|
|
||||||
| ✅ | introspect | 9 | 9 |
|
|
||||||
| ✅ | par | 7 | 7 |
|
|
||||||
| ✅ | dynreg | 5 | 5 |
|
|
||||||
| ✅ | account | 7 | 7 |
|
|
||||||
|
|
||||||
|
|
||||||
Generated by `lib/identity/conformance.sh`.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
;; identity/session.sx — a session is an Erlang process.
|
|
||||||
;;
|
|
||||||
;; create = spawn a session process holding {subject, client, status}
|
|
||||||
;; lookup = a message; the live process answers {ok, ...} or {error, S}
|
|
||||||
;; expire = explicit message OR an idle timeout the process arms itself
|
|
||||||
;; revoke = explicit message; the grant tombstones immediately
|
|
||||||
;;
|
|
||||||
;; Expiry is the process's own `receive ... after Ttl` timeout, never a
|
|
||||||
;; global sweep. On timeout the process notifies its Owner and becomes a
|
|
||||||
;; tombstone that still answers lookups — with {error, expired}, never a
|
|
||||||
;; silent dead mailbox. A revoked or expired session is an explicit
|
|
||||||
;; negative state, not the absence of a positive one.
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-session-source
|
|
||||||
"-module(identity_session).\n\n start(SessionId, Subject, Client, Owner, Ttl) ->\n spawn(fun () -> active(SessionId, Subject, Client, Owner, Ttl) end).\n\n lookup(Pid) ->\n Pid ! {lookup, self()},\n receive {session_reply, R} -> R end.\n\n touch(Pid) ->\n Pid ! {touch, self()},\n receive {session_reply, R} -> R end.\n\n expire(Pid) ->\n Pid ! {expire, self()},\n receive {session_reply, R} -> R end.\n\n revoke(Pid) ->\n Pid ! {revoke, self()},\n receive {session_reply, R} -> R end.\n\n stop(Pid) ->\n Pid ! {stop, self()},\n receive {session_reply, R} -> R end.\n\n active(SessionId, Subject, Client, Owner, Ttl) ->\n receive\n {lookup, From} ->\n From ! {session_reply, {ok, {SessionId, Subject, Client, active}}},\n active(SessionId, Subject, Client, Owner, Ttl);\n {touch, From} ->\n From ! {session_reply, ok},\n active(SessionId, Subject, Client, Owner, Ttl);\n {expire, From} ->\n From ! {session_reply, ok},\n tombstone(SessionId, Subject, Client, expired);\n {revoke, From} ->\n From ! {session_reply, ok},\n tombstone(SessionId, Subject, Client, revoked);\n {stop, From} ->\n From ! {session_reply, ok}\n after Ttl ->\n Owner ! {session_expired, SessionId},\n tombstone(SessionId, Subject, Client, expired)\n end.\n\n tombstone(SessionId, Subject, Client, Status) ->\n receive\n {lookup, From} ->\n From ! {session_reply, {error, Status}},\n tombstone(SessionId, Subject, Client, Status);\n {touch, From} ->\n From ! {session_reply, {error, Status}},\n tombstone(SessionId, Subject, Client, Status);\n {expire, From} ->\n From ! {session_reply, ok},\n tombstone(SessionId, Subject, Client, Status);\n {revoke, From} ->\n From ! {session_reply, ok},\n tombstone(SessionId, Subject, Client, revoked);\n {stop, From} ->\n From ! {session_reply, ok}\n end.")
|
|
||||||
|
|
||||||
(define
|
|
||||||
identity-load-session!
|
|
||||||
(fn () (erlang-load-module identity-session-source)))
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
;; identity/tests/account.sx — \"apps with access\": per-subject active-grant
|
|
||||||
;; listing, at the token registry (grants_for) and through the facade
|
|
||||||
;; (identity:grants). Completes the per-subject security trio with sessions
|
|
||||||
;; and history.
|
|
||||||
|
|
||||||
(define id-acct-test-count 0)
|
|
||||||
(define id-acct-test-pass 0)
|
|
||||||
(define id-acct-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-acct-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-acct-test-count (+ id-acct-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-acct-test-pass (+ id-acct-test-pass 1))
|
|
||||||
(append! id-acct-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define ida-ev erlang-eval-ast)
|
|
||||||
(define idanm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-all!)
|
|
||||||
|
|
||||||
;; ── token-level grants_for ───────────────────────────────────────
|
|
||||||
|
|
||||||
(id-acct-test
|
|
||||||
"grants_for lists a subject's active grants"
|
|
||||||
(ida-ev
|
|
||||||
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n identity_tokens:issue(R, alice, cli, write),\n identity_tokens:issue(R, bob, web, read),\n length(identity_tokens:grants_for(R, alice))")
|
|
||||||
2)
|
|
||||||
|
|
||||||
(id-acct-test
|
|
||||||
"grants_for excludes revoked grants"
|
|
||||||
(ida-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:issue(R, alice, cli, write),\n identity_tokens:revoke(R, A),\n length(identity_tokens:grants_for(R, alice))")
|
|
||||||
1)
|
|
||||||
|
|
||||||
(id-acct-test
|
|
||||||
"grants_for is empty for a subject with none"
|
|
||||||
(ida-ev
|
|
||||||
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n length(identity_tokens:grants_for(R, ghost))")
|
|
||||||
0)
|
|
||||||
|
|
||||||
(id-acct-test
|
|
||||||
"each grant entry carries the client"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n case identity_tokens:grants_for(R, alice) of\n [{Client, _Scope}] -> Client;\n _ -> other\n end"))
|
|
||||||
"web")
|
|
||||||
|
|
||||||
;; ── facade-level grants ──────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-acct-test
|
|
||||||
"identity:grants lists apps a subject has logged into"
|
|
||||||
(ida-ev
|
|
||||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, mobile, read),\n length(identity:grants(Svc, alice))")
|
|
||||||
2)
|
|
||||||
|
|
||||||
(id-acct-test
|
|
||||||
"revoking a token drops it from identity:grants"
|
|
||||||
(ida-ev
|
|
||||||
"Svc = identity:start(),\n {ok, _S1, T1} = identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, mobile, read),\n identity:revoke(Svc, T1),\n length(identity:grants(Svc, alice))")
|
|
||||||
1)
|
|
||||||
|
|
||||||
(id-acct-test
|
|
||||||
"identity:grants is per-subject"
|
|
||||||
(ida-ev
|
|
||||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, web, read),\n length(identity:grants(Svc, bob))")
|
|
||||||
1)
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-acct-test-summary
|
|
||||||
(str "account " id-acct-test-pass "/" id-acct-test-count))
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
;; identity/tests/api.sx — the service facade end-to-end: login issues a
|
|
||||||
;; session + token, verify proves identity, revoke and logout take effect
|
|
||||||
;; immediately. Exercises session + token + registry through one door.
|
|
||||||
|
|
||||||
(define id-api-test-count 0)
|
|
||||||
(define id-api-test-pass 0)
|
|
||||||
(define id-api-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-api-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-api-test-count (+ id-api-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-api-test-pass (+ id-api-test-pass 1))
|
|
||||||
(append! id-api-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define ida-ev erlang-eval-ast)
|
|
||||||
(define idanm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-all!)
|
|
||||||
|
|
||||||
;; ── login + verify (happy path) ──────────────────────────────────
|
|
||||||
|
|
||||||
(id-api-test
|
|
||||||
"login then verify is active"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n case identity:verify(Svc, Tok) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-api-test
|
|
||||||
"verify returns the logged-in subject"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n case identity:verify(Svc, Tok) of\n {active, Subject, _, _} -> Subject\n end"))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
(id-api-test
|
|
||||||
"verify returns the granted scope"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, bob, cli, write),\n case identity:verify(Svc, Tok) of\n {active, _, _, Scope} -> Scope\n end"))
|
|
||||||
"write")
|
|
||||||
|
|
||||||
;; ── revoke is real through the facade ────────────────────────────
|
|
||||||
|
|
||||||
(id-api-test
|
|
||||||
"revoked token verifies inactive immediately"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n identity:revoke(Svc, Tok),\n case identity:verify(Svc, Tok) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
;; ── session lifecycle through the facade ─────────────────────────
|
|
||||||
|
|
||||||
(id-api-test
|
|
||||||
"fresh session reports active"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read),\n identity:session_status(Svc, Sid)"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-api-test
|
|
||||||
"logout makes the session gone"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read),\n identity:logout(Svc, Sid),\n identity:session_status(Svc, Sid)"))
|
|
||||||
"gone")
|
|
||||||
|
|
||||||
(id-api-test
|
|
||||||
"status of an unknown session is gone"
|
|
||||||
(idanm
|
|
||||||
(ida-ev "Svc = identity:start(),\n identity:session_status(Svc, 999)"))
|
|
||||||
"gone")
|
|
||||||
|
|
||||||
;; ── independence: logins do not bleed into each other ────────────
|
|
||||||
|
|
||||||
(id-api-test
|
|
||||||
"revoking one login leaves the other active"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"Svc = identity:start(),\n {ok, _S1, T1} = identity:login(Svc, alice, web, read),\n {ok, _S2, T2} = identity:login(Svc, bob, cli, write),\n identity:revoke(Svc, T1),\n case identity:verify(Svc, T2) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
|
|
||||||
"bob")
|
|
||||||
|
|
||||||
(id-api-test
|
|
||||||
"logging out one session leaves the other active"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"Svc = identity:start(),\n {ok, S1, _T1} = identity:login(Svc, alice, web, read),\n {ok, S2, _T2} = identity:login(Svc, alice, cli, read),\n identity:logout(Svc, S1),\n identity:session_status(Svc, S2)"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
;; ── coordinator deregisters on a session_expired notification ────
|
|
||||||
;; A live idle session fires its own `after` timeout and notifies its
|
|
||||||
;; owner (the coordinator), which then deregisters it — timeout-driven,
|
|
||||||
;; never swept. The owner-internal path can't be observed by driving the
|
|
||||||
;; scheduler idle from the test's main process, so we assert the handler
|
|
||||||
;; directly: the mailbox is FIFO, so the expiry notification is processed
|
|
||||||
;; before the following status query.
|
|
||||||
|
|
||||||
(id-api-test
|
|
||||||
"session_expired notification deregisters the session"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read, 50),\n active = identity:session_status(Svc, Sid),\n Svc ! {session_expired, Sid},\n identity:session_status(Svc, Sid)"))
|
|
||||||
"gone")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-api-test-summary
|
|
||||||
(str "api " id-api-test-pass "/" id-api-test-count))
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
;; identity/tests/audit.sx — the grant audit ledger. Every grant
|
|
||||||
;; transition is recorded; the ledger is queryable per subject and
|
|
||||||
;; chronological. Covers issue/refresh/revoke wiring through the token
|
|
||||||
;; registry, reuse-triggered revoke, per-subject isolation, completeness,
|
|
||||||
;; and direct ledger use.
|
|
||||||
|
|
||||||
(define id-audit-test-count 0)
|
|
||||||
(define id-audit-test-pass 0)
|
|
||||||
(define id-audit-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-audit-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-audit-test-count (+ id-audit-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-audit-test-pass (+ id-audit-test-pass 1))
|
|
||||||
(append! id-audit-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define ida-ev erlang-eval-ast)
|
|
||||||
(define idanm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-audit!)
|
|
||||||
(identity-load-token!)
|
|
||||||
|
|
||||||
;; ── issue is audited ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-audit-test
|
|
||||||
"issue records one event for the subject"
|
|
||||||
(ida-ev
|
|
||||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_audit:count(A, alice)")
|
|
||||||
1)
|
|
||||||
|
|
||||||
(id-audit-test
|
|
||||||
"the recorded action is issue"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n case identity_audit:actions(A, alice) of\n [issue] -> matched;\n _ -> nomatch\n end"))
|
|
||||||
"matched")
|
|
||||||
|
|
||||||
;; ── full grant lifecycle is audited in order ─────────────────────
|
|
||||||
|
|
||||||
(id-audit-test
|
|
||||||
"issue, refresh, revoke are recorded in order"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n {ok, G, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:refresh(Reg, R),\n identity_tokens:revoke(Reg, G),\n case identity_audit:actions(A, alice) of\n [issue, refresh, revoke] -> matched;\n _ -> nomatch\n end"))
|
|
||||||
"matched")
|
|
||||||
|
|
||||||
;; ── reuse-triggered revoke is audited ────────────────────────────
|
|
||||||
|
|
||||||
(id-audit-test
|
|
||||||
"a refresh-reuse cascade records a revoke event"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n {ok, _G, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:refresh(Reg, R),\n identity_tokens:refresh(Reg, R),\n case identity_audit:actions(A, alice) of\n [issue, refresh, revoke] -> matched;\n _ -> nomatch\n end"))
|
|
||||||
"matched")
|
|
||||||
|
|
||||||
;; ── per-subject isolation ────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-audit-test
|
|
||||||
"the ledger separates subjects"
|
|
||||||
(ida-ev
|
|
||||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_tokens:issue(Reg, bob, cli, write),\n identity_tokens:issue(Reg, alice, mobile, read),\n identity_audit:count(A, alice)")
|
|
||||||
2)
|
|
||||||
|
|
||||||
(id-audit-test
|
|
||||||
"an unaudited subject has zero events"
|
|
||||||
(ida-ev
|
|
||||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_audit:count(A, ghost)")
|
|
||||||
0)
|
|
||||||
|
|
||||||
;; ── the full log accumulates across subjects ─────────────────────
|
|
||||||
|
|
||||||
(id-audit-test
|
|
||||||
"all events accumulate in the ledger"
|
|
||||||
(ida-ev
|
|
||||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_tokens:issue(Reg, bob, cli, write),\n length(identity_audit:all(A))")
|
|
||||||
2)
|
|
||||||
|
|
||||||
;; ── completeness: no grant transition is dropped ─────────────────
|
|
||||||
|
|
||||||
(id-audit-test
|
|
||||||
"the ledger is complete across a mixed transition stream"
|
|
||||||
(ida-ev
|
|
||||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n {ok, _G, R} = identity_tokens:issue_grant(Reg, alice, cli, read),\n identity_tokens:refresh(Reg, R),\n {ok, B} = identity_tokens:issue(Reg, bob, web, read),\n identity_tokens:revoke(Reg, B),\n length(identity_audit:all(A))")
|
|
||||||
5)
|
|
||||||
|
|
||||||
;; ── start/0 stays unaudited (no regression) ──────────────────────
|
|
||||||
|
|
||||||
(id-audit-test
|
|
||||||
"an unaudited registry still issues working tokens"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
;; ── direct ledger use (e.g. login/consent events) ────────────────
|
|
||||||
|
|
||||||
(id-audit-test
|
|
||||||
"events can be recorded directly on the ledger"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"A = identity_audit:start(),\n identity_audit:record(A, alice, login),\n identity_audit:record(A, alice, consent),\n case identity_audit:actions(A, alice) of\n [login, consent] -> matched;\n _ -> nomatch\n end"))
|
|
||||||
"matched")
|
|
||||||
|
|
||||||
(id-audit-test
|
|
||||||
"an audit entry carries its subject"
|
|
||||||
(idanm
|
|
||||||
(ida-ev
|
|
||||||
"A = identity_audit:start(),\n identity_audit:record(A, alice, login),\n case identity_audit:audit(A, alice) of\n [{_, Subject, _}] -> Subject;\n _ -> nomatch\n end"))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-audit-test-summary
|
|
||||||
(str "audit " id-audit-test-pass "/" id-audit-test-count))
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
;; identity/tests/cache.sx — delegated grant-verification cache. Proves
|
|
||||||
;; the cache is live (hits/misses) AND that revocation stays real: a
|
|
||||||
;; revoked token never reads valid out of the cache, because any revoke
|
|
||||||
;; bumps the generation and forces re-validation.
|
|
||||||
|
|
||||||
(define id-cache-test-count 0)
|
|
||||||
(define id-cache-test-pass 0)
|
|
||||||
(define id-cache-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-cache-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-cache-test-count (+ id-cache-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-cache-test-pass (+ id-cache-test-pass 1))
|
|
||||||
(append! id-cache-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idc-ev erlang-eval-ast)
|
|
||||||
(define idcnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-token!)
|
|
||||||
(identity-load-cache!)
|
|
||||||
|
|
||||||
;; ── delegation: cache forwards to the registry ───────────────────
|
|
||||||
|
|
||||||
(id-cache-test
|
|
||||||
"introspect through the cache returns active"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n case identity_grant_cache:introspect(C, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
;; ── the cache is actually caching ────────────────────────────────
|
|
||||||
|
|
||||||
(id-cache-test
|
|
||||||
"a repeated introspect is a cache hit"
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:introspect(C, T),\n case identity_grant_cache:stats(C) of {H, _} -> H end")
|
|
||||||
1)
|
|
||||||
|
|
||||||
(id-cache-test
|
|
||||||
"the first introspect of a token is a miss"
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:introspect(C, T),\n case identity_grant_cache:stats(C) of {_, M} -> M end")
|
|
||||||
1)
|
|
||||||
|
|
||||||
;; ── revocation stays real through the cache (the centrepiece) ─────
|
|
||||||
|
|
||||||
(id-cache-test
|
|
||||||
"a revoked token introspects inactive through the cache"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:revoke(C, T),\n case identity_grant_cache:introspect(C, T) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
(id-cache-test
|
|
||||||
"revoke invalidates the cache (post-revoke read re-validates)"
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:revoke(C, T),\n identity_grant_cache:introspect(C, T),\n case identity_grant_cache:stats(C) of {_, M} -> M end")
|
|
||||||
2)
|
|
||||||
|
|
||||||
;; ── cascade visibility through the cache ──────────────────────────
|
|
||||||
|
|
||||||
(id-cache-test
|
|
||||||
"cascade revocation is visible through the cache"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_grant_cache:start(),\n {ok, A, R} = identity_grant_cache:issue_grant(C, alice, web, read),\n identity_grant_cache:introspect(C, A),\n identity_grant_cache:revoke(C, R),\n case identity_grant_cache:introspect(C, A) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
;; ── a sibling token re-validates correctly after a revoke ────────
|
|
||||||
|
|
||||||
(id-cache-test
|
|
||||||
"revoking one token leaves an independent token valid"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_grant_cache:start(),\n {ok, A} = identity_grant_cache:issue(C, alice, web, read),\n {ok, B} = identity_grant_cache:issue(C, bob, cli, write),\n identity_grant_cache:introspect(C, A),\n identity_grant_cache:introspect(C, B),\n identity_grant_cache:revoke(C, A),\n case identity_grant_cache:introspect(C, B) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
|
|
||||||
"bob")
|
|
||||||
|
|
||||||
;; ── refresh flows through the cache and stays correct ────────────
|
|
||||||
|
|
||||||
(id-cache-test
|
|
||||||
"a refreshed token introspects active through the cache"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_grant_cache:start(),\n {ok, _A, R} = identity_grant_cache:issue_grant(C, alice, web, read),\n {ok, A2, _R2} = identity_grant_cache:refresh(C, R),\n case identity_grant_cache:introspect(C, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
;; ── unknown token is inactive, and cached as such ────────────────
|
|
||||||
|
|
||||||
(id-cache-test
|
|
||||||
"an unknown token introspects inactive through the cache"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_grant_cache:start(),\n Bogus = make_ref(),\n case identity_grant_cache:introspect(C, Bogus) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-cache-test-summary
|
|
||||||
(str "cache " id-cache-test-pass "/" id-cache-test-count))
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
;; identity/tests/clients.sx — OAuth client registry: registration,
|
|
||||||
;; public vs confidential authentication, and redirect_uri allow-listing.
|
|
||||||
|
|
||||||
(define id-clients-test-count 0)
|
|
||||||
(define id-clients-test-pass 0)
|
|
||||||
(define id-clients-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-clients-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-clients-test-count (+ id-clients-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-clients-test-pass (+ id-clients-test-pass 1))
|
|
||||||
(append! id-clients-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idc-ev erlang-eval-ast)
|
|
||||||
(define idcnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-clients!)
|
|
||||||
|
|
||||||
;; ── registration + lookup ────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-clients-test
|
|
||||||
"a registered client looks up its type"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:lookup(C, app1) of\n {ok, Type, _} -> Type;\n {error, W} -> W\n end"))
|
|
||||||
"confidential")
|
|
||||||
|
|
||||||
(id-clients-test
|
|
||||||
"registering the same client twice is an error"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:register(C, app1, public, none, [uri1]) of\n ok -> ok;\n {error, W} -> W\n end"))
|
|
||||||
"exists")
|
|
||||||
|
|
||||||
(id-clients-test
|
|
||||||
"looking up an unregistered client is unknown_client"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_clients:start(),\n case identity_clients:lookup(C, ghost) of\n {ok, _, _} -> found;\n {error, W} -> W\n end"))
|
|
||||||
"unknown_client")
|
|
||||||
|
|
||||||
;; ── confidential client authentication ───────────────────────────
|
|
||||||
|
|
||||||
(id-clients-test
|
|
||||||
"a confidential client authenticates with the right secret"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:authenticate(C, app1, s3cret) of\n {ok, Kind} -> Kind;\n {error, W} -> W\n end"))
|
|
||||||
"confidential")
|
|
||||||
|
|
||||||
(id-clients-test
|
|
||||||
"a confidential client with the wrong secret is invalid_client"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:authenticate(C, app1, wrongsecret) of\n {ok, _} -> accepted;\n {error, W} -> W\n end"))
|
|
||||||
"invalid_client")
|
|
||||||
|
|
||||||
(id-clients-test
|
|
||||||
"a public client needs no secret to authenticate"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_clients:start(),\n identity_clients:register(C, spa, public, none, [uri1]),\n case identity_clients:authenticate(C, spa, anything) of\n {ok, Kind} -> Kind;\n {error, W} -> W\n end"))
|
|
||||||
"public")
|
|
||||||
|
|
||||||
(id-clients-test
|
|
||||||
"authenticating an unknown client is unknown_client"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_clients:start(),\n case identity_clients:authenticate(C, ghost, x) of\n {ok, _} -> accepted;\n {error, W} -> W\n end"))
|
|
||||||
"unknown_client")
|
|
||||||
|
|
||||||
;; ── redirect_uri allow-listing ───────────────────────────────────
|
|
||||||
|
|
||||||
(id-clients-test
|
|
||||||
"a registered redirect_uri is valid"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1, uri2]),\n case identity_clients:valid_redirect(C, app1, uri1) of\n true -> yes;\n false -> no\n end"))
|
|
||||||
"yes")
|
|
||||||
|
|
||||||
(id-clients-test
|
|
||||||
"a second registered redirect_uri is also valid"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1, uri2]),\n case identity_clients:valid_redirect(C, app1, uri2) of\n true -> yes;\n false -> no\n end"))
|
|
||||||
"yes")
|
|
||||||
|
|
||||||
(id-clients-test
|
|
||||||
"an unregistered redirect_uri is rejected"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:valid_redirect(C, app1, evil_uri) of\n true -> yes;\n false -> no\n end"))
|
|
||||||
"no")
|
|
||||||
|
|
||||||
(id-clients-test
|
|
||||||
"redirect validation for an unknown client is rejected"
|
|
||||||
(idcnm
|
|
||||||
(idc-ev
|
|
||||||
"C = identity_clients:start(),\n case identity_clients:valid_redirect(C, ghost, uri1) of\n true -> yes;\n false -> no\n end"))
|
|
||||||
"no")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-clients-test-summary
|
|
||||||
(str "clients " id-clients-test-pass "/" id-clients-test-count))
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
;; identity/tests/delegation.sx — the identity -> acl boundary.
|
|
||||||
;; Authentication (identity) gates BEFORE authorization (acl): an inactive
|
|
||||||
;; token is unauthenticated (401) and acl is never consulted; only an
|
|
||||||
;; authenticated subject's request is delegated to acl for permit/deny.
|
|
||||||
|
|
||||||
(define id-deleg-test-count 0)
|
|
||||||
(define id-deleg-test-pass 0)
|
|
||||||
(define id-deleg-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-deleg-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-deleg-test-count (+ id-deleg-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-deleg-test-pass (+ id-deleg-test-pass 1))
|
|
||||||
(append! id-deleg-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idl-ev erlang-eval-ast)
|
|
||||||
(define idlnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-delegation!)
|
|
||||||
|
|
||||||
;; Shared prelude: a token registry, a stub acl, and a token granting
|
|
||||||
;; [read, write] to alice, all bound.
|
|
||||||
(define
|
|
||||||
idl-setup
|
|
||||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read, write])")
|
|
||||||
|
|
||||||
;; ── authenticated + acl permits ──────────────────────────────────
|
|
||||||
|
|
||||||
(id-deleg-test
|
|
||||||
"an authenticated, permitted request returns the subject"
|
|
||||||
(idlnm
|
|
||||||
(idl-ev
|
|
||||||
(str
|
|
||||||
idl-setup
|
|
||||||
", case identity_delegation:check(R, A, T, read, doc1) of\n {ok, S} -> S;\n {error, W} -> W\n end")))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
;; ── authenticated + acl denies → 403 ─────────────────────────────
|
|
||||||
|
|
||||||
(id-deleg-test
|
|
||||||
"an authenticated but unpermitted request is forbidden"
|
|
||||||
(idlnm
|
|
||||||
(idl-ev
|
|
||||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read]),\n case identity_delegation:check(R, A, T, write, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
|
|
||||||
"forbidden")
|
|
||||||
|
|
||||||
;; ── unauthenticated → 401, acl never consulted ───────────────────
|
|
||||||
|
|
||||||
(id-deleg-test
|
|
||||||
"a revoked token is unauthenticated, not forbidden"
|
|
||||||
(idlnm
|
|
||||||
(idl-ev
|
|
||||||
(str
|
|
||||||
idl-setup
|
|
||||||
", identity_tokens:revoke(R, T),\n case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end")))
|
|
||||||
"unauthenticated")
|
|
||||||
|
|
||||||
(id-deleg-test
|
|
||||||
"an unknown token is unauthenticated"
|
|
||||||
(idlnm
|
|
||||||
(idl-ev
|
|
||||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n Bogus = make_ref(),\n case identity_delegation:check(R, A, Bogus, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
|
|
||||||
"unauthenticated")
|
|
||||||
|
|
||||||
(id-deleg-test
|
|
||||||
"an expired token is unauthenticated"
|
|
||||||
(idlnm
|
|
||||||
(idl-ev
|
|
||||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read], 100),\n identity_tokens:advance(R, 100),\n case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
|
|
||||||
"unauthenticated")
|
|
||||||
|
|
||||||
;; ── 401 takes precedence over 403 (identity gates first) ─────────
|
|
||||||
|
|
||||||
(id-deleg-test
|
|
||||||
"a revoked token with no matching scope is still unauthenticated"
|
|
||||||
(idlnm
|
|
||||||
(idl-ev
|
|
||||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [admin]),\n identity_tokens:revoke(R, T),\n case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
|
|
||||||
"unauthenticated")
|
|
||||||
|
|
||||||
;; ── acl is what decides for an authenticated subject ─────────────
|
|
||||||
|
|
||||||
(id-deleg-test
|
|
||||||
"the same subject is permitted one action and denied another"
|
|
||||||
(idl-ev
|
|
||||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read]),\n Allowed = case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> 1; {error, _} -> 0 end,\n Denied = case identity_delegation:check(R, A, T, write, doc1) of\n {ok, _} -> 1; {error, _} -> 0 end,\n Allowed - Denied")
|
|
||||||
1)
|
|
||||||
|
|
||||||
(id-deleg-test
|
|
||||||
"identity does not widen permission beyond the token scope"
|
|
||||||
(idlnm
|
|
||||||
(idl-ev
|
|
||||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read, write]),\n case identity_delegation:check(R, A, T, delete, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
|
|
||||||
"forbidden")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-deleg-test-summary
|
|
||||||
(str "delegation " id-deleg-test-pass "/" id-deleg-test-count))
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
;; identity/tests/device.sx — device authorization grant (RFC 8628):
|
|
||||||
;; authorize → poll(pending) → approve/deny out-of-band → poll(token/denied).
|
|
||||||
|
|
||||||
(define id-device-test-count 0)
|
|
||||||
(define id-device-test-pass 0)
|
|
||||||
(define id-device-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-device-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-device-test-count (+ id-device-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-device-test-pass (+ id-device-test-pass 1))
|
|
||||||
(append! id-device-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idd-ev erlang-eval-ast)
|
|
||||||
(define iddnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-device!)
|
|
||||||
|
|
||||||
;; ── polling before approval ──────────────────────────────────────
|
|
||||||
|
|
||||||
(id-device-test
|
|
||||||
"polling a pending device code is authorization_pending"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"D = identity_device:start(),\n {ok, Dc, _Uc} = identity_device:authorize(D, tv, watch),\n case identity_device:poll(D, Dc) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
|
|
||||||
"authorization_pending")
|
|
||||||
|
|
||||||
;; ── approve → token ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-device-test
|
|
||||||
"after approval, polling yields a working token"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:approve(D, Uc, alice),\n {ok, T} = identity_device:poll(D, Dc),\n case identity_device:introspect(D, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-device-test
|
|
||||||
"the device token carries the approving subject"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:approve(D, Uc, alice),\n {ok, T} = identity_device:poll(D, Dc),\n case identity_device:introspect(D, T) of\n {active, Subject, _, _} -> Subject\n end"))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
(id-device-test
|
|
||||||
"the device token carries the requested scope"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, stream),\n identity_device:approve(D, Uc, alice),\n {ok, T} = identity_device:poll(D, Dc),\n case identity_device:introspect(D, T) of\n {active, _, _, Scope} -> Scope\n end"))
|
|
||||||
"stream")
|
|
||||||
|
|
||||||
;; ── deny ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-device-test
|
|
||||||
"after denial, polling is access_denied"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:deny(D, Uc),\n case identity_device:poll(D, Dc) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
|
|
||||||
"access_denied")
|
|
||||||
|
|
||||||
;; ── unknown codes ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-device-test
|
|
||||||
"polling an unknown device code is invalid_grant"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"D = identity_device:start(),\n Bogus = make_ref(),\n case identity_device:poll(D, Bogus) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
(id-device-test
|
|
||||||
"approving an unknown user code is unknown_code"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"D = identity_device:start(),\n Bogus = make_ref(),\n case identity_device:approve(D, Bogus, alice) of\n ok -> ok;\n {error, W} -> W\n end"))
|
|
||||||
"unknown_code")
|
|
||||||
|
|
||||||
;; ── single-use device code ───────────────────────────────────────
|
|
||||||
|
|
||||||
(id-device-test
|
|
||||||
"the device code is single-use after issuing a token"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:approve(D, Uc, alice),\n identity_device:poll(D, Dc),\n case identity_device:poll(D, Dc) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
;; ── guarded transitions ──────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-device-test
|
|
||||||
"approving an already-denied request is rejected"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"D = identity_device:start(),\n {ok, _Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:deny(D, Uc),\n case identity_device:approve(D, Uc, alice) of\n ok -> ok;\n {error, W} -> W\n end"))
|
|
||||||
"denied")
|
|
||||||
|
|
||||||
;; ── independence ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-device-test
|
|
||||||
"two device requests are independent"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"D = identity_device:start(),\n {ok, Dc1, Uc1} = identity_device:authorize(D, tv, watch),\n {ok, Dc2, _Uc2} = identity_device:authorize(D, cli, deploy),\n identity_device:approve(D, Uc1, alice),\n case identity_device:poll(D, Dc2) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
|
|
||||||
"authorization_pending")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-device-test-summary
|
|
||||||
(str "device " id-device-test-pass "/" id-device-test-count))
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
;; identity/tests/dynreg.sx — dynamic client registration (RFC 7591): the
|
|
||||||
;; server generates the client_id + secret for self-service onboarding.
|
|
||||||
|
|
||||||
(define id-dyn-test-count 0)
|
|
||||||
(define id-dyn-test-pass 0)
|
|
||||||
(define id-dyn-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-dyn-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-dyn-test-count (+ id-dyn-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-dyn-test-pass (+ id-dyn-test-pass 1))
|
|
||||||
(append! id-dyn-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idd-ev erlang-eval-ast)
|
|
||||||
(define iddnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-oauth!)
|
|
||||||
|
|
||||||
;; ── self-service registration yields usable credentials ──────────
|
|
||||||
|
|
||||||
(id-dyn-test
|
|
||||||
"a dynamically registered confidential client can get a token"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, Cid, Sec} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, Cid, Sec, batch),\n case identity_oauth:introspect(O, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-dyn-test
|
|
||||||
"the token's subject is the generated client id"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, Cid, Sec} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, Cid, Sec, batch),\n case identity_oauth:introspect(O, T) of\n {active, Sub, _, _} ->\n case Sub =:= Cid of true -> matches; false -> mismatch end;\n {inactive} -> inactive\n end"))
|
|
||||||
"matches")
|
|
||||||
|
|
||||||
;; ── the generated secret is required ─────────────────────────────
|
|
||||||
|
|
||||||
(id-dyn-test
|
|
||||||
"a wrong secret for a dynamic client is invalid_client"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, Cid, _Sec} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n case identity_oauth:client_credentials(O, Cid, wrongsecret, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
|
|
||||||
"invalid_client")
|
|
||||||
|
|
||||||
;; ── uniqueness ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-dyn-test
|
|
||||||
"two registrations yield distinct client ids"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, C1, _} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n {ok, C2, _} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n case C1 =:= C2 of true -> collision; false -> distinct end"))
|
|
||||||
"distinct")
|
|
||||||
|
|
||||||
;; ── a dynamic public client still cannot use client-credentials ──
|
|
||||||
|
|
||||||
(id-dyn-test
|
|
||||||
"a dynamic public client is unauthorized for client-credentials"
|
|
||||||
(iddnm
|
|
||||||
(idd-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, Cid, Sec} = identity_oauth:register_dynamic(O, public, [uri1]),\n case identity_oauth:client_credentials(O, Cid, Sec, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
|
|
||||||
"unauthorized_client")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-dyn-test-summary
|
|
||||||
(str "dynreg " id-dyn-test-pass "/" id-dyn-test-count))
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
;; identity/tests/exchange.sx — token exchange (RFC 8693 §2.1): downscope a
|
|
||||||
;; valid access token into a new independent token for a downstream service.
|
|
||||||
|
|
||||||
(define id-xchg-test-count 0)
|
|
||||||
(define id-xchg-test-pass 0)
|
|
||||||
(define id-xchg-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-xchg-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-xchg-test-count (+ id-xchg-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-xchg-test-pass (+ id-xchg-test-pass 1))
|
|
||||||
(append! id-xchg-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idx-ev erlang-eval-ast)
|
|
||||||
(define idxnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-oauth!)
|
|
||||||
|
|
||||||
;; Shared prelude: an access token A for alice with scope [read, write].
|
|
||||||
(define
|
|
||||||
idx-token
|
|
||||||
"O = identity_oauth:start(),\n {consent_required, Rq} = identity_oauth:authorize(O, web, uri1, [read, write], alice, v),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v)")
|
|
||||||
|
|
||||||
;; ── downscoping ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-xchg-test
|
|
||||||
"exchange downscopes to a subset"
|
|
||||||
(idxnm
|
|
||||||
(idx-ev
|
|
||||||
(str
|
|
||||||
idx-token
|
|
||||||
", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n case identity_oauth:introspect(O, X) of\n {active, _, _, [read]} -> downscoped;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end")))
|
|
||||||
"downscoped")
|
|
||||||
|
|
||||||
(id-xchg-test
|
|
||||||
"the exchanged token keeps the subject"
|
|
||||||
(idxnm
|
|
||||||
(idx-ev
|
|
||||||
(str
|
|
||||||
idx-token
|
|
||||||
", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n case identity_oauth:introspect(O, X) of\n {active, Subject, _, _} -> Subject\n end")))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
(id-xchg-test
|
|
||||||
"exchange to the same scope is allowed"
|
|
||||||
(idxnm
|
|
||||||
(idx-ev
|
|
||||||
(str
|
|
||||||
idx-token
|
|
||||||
", {ok, X} = identity_oauth:token_exchange(O, A, [read, write]),\n case identity_oauth:introspect(O, X) of\n {active, _, _, [read, write]} -> full;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end")))
|
|
||||||
"full")
|
|
||||||
|
|
||||||
;; ── scope cannot be widened ──────────────────────────────────────
|
|
||||||
|
|
||||||
(id-xchg-test
|
|
||||||
"exchange cannot widen beyond the subject token's scope"
|
|
||||||
(idxnm
|
|
||||||
(idx-ev
|
|
||||||
"O = identity_oauth:start(),\n {consent_required, Rq} = identity_oauth:authorize(O, web, uri1, [read], alice, v),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v),\n case identity_oauth:token_exchange(O, A, [read, write]) of\n {ok, _} -> widened;\n {error, W} -> W\n end"))
|
|
||||||
"invalid_scope")
|
|
||||||
|
|
||||||
;; ── inactive subject token cannot be exchanged ───────────────────
|
|
||||||
|
|
||||||
(id-xchg-test
|
|
||||||
"exchanging a revoked subject token is invalid_grant"
|
|
||||||
(idxnm
|
|
||||||
(idx-ev
|
|
||||||
(str
|
|
||||||
idx-token
|
|
||||||
", identity_oauth:revoke(O, A),\n case identity_oauth:token_exchange(O, A, [read]) of\n {ok, _} -> issued;\n {error, W} -> W\n end")))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
;; ── independent lifecycles ───────────────────────────────────────
|
|
||||||
|
|
||||||
(id-xchg-test
|
|
||||||
"revoking the subject token does not revoke the exchanged token"
|
|
||||||
(idxnm
|
|
||||||
(idx-ev
|
|
||||||
(str
|
|
||||||
idx-token
|
|
||||||
", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n identity_oauth:revoke(O, A),\n case identity_oauth:introspect(O, X) of\n {active, _, _, _} -> still_active;\n {inactive} -> inactive\n end")))
|
|
||||||
"still_active")
|
|
||||||
|
|
||||||
(id-xchg-test
|
|
||||||
"revoking the exchanged token does not revoke the subject token"
|
|
||||||
(idxnm
|
|
||||||
(idx-ev
|
|
||||||
(str
|
|
||||||
idx-token
|
|
||||||
", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n identity_oauth:revoke(O, X),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> still_active;\n {inactive} -> inactive\n end")))
|
|
||||||
"still_active")
|
|
||||||
|
|
||||||
;; ── chained downscoping ──────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-xchg-test
|
|
||||||
"an exchanged token can itself be exchanged (chain)"
|
|
||||||
(idxnm
|
|
||||||
(idx-ev
|
|
||||||
(str
|
|
||||||
idx-token
|
|
||||||
", {ok, X1} = identity_oauth:token_exchange(O, A, [read, write]),\n {ok, X2} = identity_oauth:token_exchange(O, X1, [read]),\n case identity_oauth:introspect(O, X2) of\n {active, _, _, [read]} -> chained;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end")))
|
|
||||||
"chained")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-xchg-test-summary
|
|
||||||
(str "exchange " id-xchg-test-pass "/" id-xchg-test-count))
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
;; identity/tests/expiry.sx — access-token expiry on a logical clock
|
|
||||||
;; (RFC 6749 §4.2.2 expires_in). `advance` stands in for time passing;
|
|
||||||
;; introspect returns inactive once the clock reaches a token's expiry.
|
|
||||||
;; Refresh mints a fresh short-lived access token — the point of refresh.
|
|
||||||
|
|
||||||
(define id-expiry-test-count 0)
|
|
||||||
(define id-expiry-test-pass 0)
|
|
||||||
(define id-expiry-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-expiry-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-expiry-test-count (+ id-expiry-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-expiry-test-pass (+ id-expiry-test-pass 1))
|
|
||||||
(append! id-expiry-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define ide-ev erlang-eval-ast)
|
|
||||||
(define idenm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-token!)
|
|
||||||
|
|
||||||
;; ── within TTL is active; past TTL is inactive ───────────────────
|
|
||||||
|
|
||||||
(id-expiry-test
|
|
||||||
"a token within its TTL is active"
|
|
||||||
(idenm
|
|
||||||
(ide-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 50),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-expiry-test
|
|
||||||
"a token at its TTL boundary is expired"
|
|
||||||
(idenm
|
|
||||||
(ide-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
(id-expiry-test
|
|
||||||
"a token just before its TTL is still active"
|
|
||||||
(idenm
|
|
||||||
(ide-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 99),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
;; ── no TTL (infinity) never expires ──────────────────────────────
|
|
||||||
|
|
||||||
(id-expiry-test
|
|
||||||
"a token issued without a TTL never expires"
|
|
||||||
(idenm
|
|
||||||
(ide-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:advance(R, 100000),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
;; ── refresh mints a fresh short-lived token ──────────────────────
|
|
||||||
|
|
||||||
(id-expiry-test
|
|
||||||
"refresh renews access after the old token expired"
|
|
||||||
(idenm
|
|
||||||
(ide-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, A, Rt} = identity_tokens:issue_grant(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n inactive = case identity_tokens:introspect(R, A) of\n {active, _, _, _} -> active; {inactive} -> inactive end,\n {ok, A2, _R2} = identity_tokens:refresh(R, Rt),\n case identity_tokens:introspect(R, A2) of\n {active, _, _, _} -> renewed;\n {inactive} -> inactive\n end"))
|
|
||||||
"renewed")
|
|
||||||
|
|
||||||
(id-expiry-test
|
|
||||||
"the renewed token also expires after its own TTL"
|
|
||||||
(idenm
|
|
||||||
(ide-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, _A, Rt} = identity_tokens:issue_grant(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n {ok, A2, _R2} = identity_tokens:refresh(R, Rt),\n identity_tokens:advance(R, 100),\n case identity_tokens:introspect(R, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
;; ── the logical clock ────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-expiry-test
|
|
||||||
"the clock starts at zero and advances"
|
|
||||||
(ide-ev
|
|
||||||
"R = identity_tokens:start(),\n identity_tokens:advance(R, 7),\n identity_tokens:advance(R, 35),\n identity_tokens:now(R)")
|
|
||||||
42)
|
|
||||||
|
|
||||||
;; ── expiry composes with revocation ──────────────────────────────
|
|
||||||
|
|
||||||
(id-expiry-test
|
|
||||||
"an expired token is also inactive after revoke (no contradiction)"
|
|
||||||
(idenm
|
|
||||||
(ide-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 200),\n identity_tokens:revoke(R, T),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-expiry-test-summary
|
|
||||||
(str "expiry " id-expiry-test-pass "/" id-expiry-test-count))
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
;; identity/tests/facade.sx — the unified facade: one coordinator wiring
|
|
||||||
;; sessions+tokens, the audit ledger, and membership. Exercises the
|
|
||||||
;; cross-module integration (login/logout auditing, audit history, member
|
|
||||||
;; enrollment + projection) through the single `identity` door.
|
|
||||||
|
|
||||||
(define id-facade-test-count 0)
|
|
||||||
(define id-facade-test-pass 0)
|
|
||||||
(define id-facade-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-facade-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-facade-test-count (+ id-facade-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-facade-test-pass (+ id-facade-test-pass 1))
|
|
||||||
(append! id-facade-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idfc-ev erlang-eval-ast)
|
|
||||||
(define idfcnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-all!)
|
|
||||||
|
|
||||||
;; ── login + logout are audited through the ledger ────────────────
|
|
||||||
|
|
||||||
(id-facade-test
|
|
||||||
"login then logout records login, issue, logout in order"
|
|
||||||
(idfcnm
|
|
||||||
(idfc-ev
|
|
||||||
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read),\n identity:logout(Svc, Sid),\n case identity:history(Svc, alice) of\n [login, issue, logout] -> ordered;\n Other -> Other\n end"))
|
|
||||||
"ordered")
|
|
||||||
|
|
||||||
(id-facade-test
|
|
||||||
"revoking a token is audited"
|
|
||||||
(idfcnm
|
|
||||||
(idfc-ev
|
|
||||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n identity:revoke(Svc, Tok),\n case identity:history(Svc, alice) of\n [login, issue, revoke] -> ordered;\n Other -> Other\n end"))
|
|
||||||
"ordered")
|
|
||||||
|
|
||||||
(id-facade-test
|
|
||||||
"history is per-subject"
|
|
||||||
(idfc-ev
|
|
||||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, cli, read),\n identity:login(Svc, alice, mobile, read),\n length(identity:history(Svc, alice))")
|
|
||||||
4)
|
|
||||||
|
|
||||||
;; ── membership through the facade ────────────────────────────────
|
|
||||||
|
|
||||||
(id-facade-test
|
|
||||||
"enroll makes the subject an active member"
|
|
||||||
(idfcnm
|
|
||||||
(idfc-ev
|
|
||||||
"Svc = identity:start(),\n identity:enroll(Svc, alice, supporter),\n case identity:member_status(Svc, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-facade-test
|
|
||||||
"enroll keeps the tier"
|
|
||||||
(idfcnm
|
|
||||||
(idfc-ev
|
|
||||||
"Svc = identity:start(),\n identity:enroll(Svc, alice, supporter),\n case identity:member_status(Svc, alice) of\n {ok, _, Tier} -> Tier\n end"))
|
|
||||||
"supporter")
|
|
||||||
|
|
||||||
(id-facade-test
|
|
||||||
"an enrolled member projects per-app"
|
|
||||||
(idfcnm
|
|
||||||
(idfc-ev
|
|
||||||
"Svc = identity:start(),\n identity:enroll(Svc, alice, basic),\n case identity:member_project(Svc, alice, market) of\n {member, _, App} -> App;\n {Tag, _} -> Tag\n end"))
|
|
||||||
"market")
|
|
||||||
|
|
||||||
(id-facade-test
|
|
||||||
"a non-member projects as non_member"
|
|
||||||
(idfcnm
|
|
||||||
(idfc-ev
|
|
||||||
"Svc = identity:start(),\n case identity:member_project(Svc, stranger, blog) of\n {member, _, _} -> member;\n {Tag, _} -> Tag\n end"))
|
|
||||||
"non_member")
|
|
||||||
|
|
||||||
;; ── the facade still proves identity ─────────────────────────────
|
|
||||||
|
|
||||||
(id-facade-test
|
|
||||||
"verify still returns the subject after login"
|
|
||||||
(idfcnm
|
|
||||||
(idfc-ev
|
|
||||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n case identity:verify(Svc, Tok) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
;; ── identity and membership are distinct axes ────────────────────
|
|
||||||
|
|
||||||
(id-facade-test
|
|
||||||
"logging in does not enroll membership"
|
|
||||||
(idfcnm
|
|
||||||
(idfc-ev
|
|
||||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n case identity:member_status(Svc, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
|
|
||||||
"none")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-facade-test-summary
|
|
||||||
(str "facade " id-facade-test-pass "/" id-facade-test-count))
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
;; identity/tests/federation.sx — federated identity: trust-gated,
|
|
||||||
;; advisory peer assertions + cross-instance subject mapping.
|
|
||||||
|
|
||||||
(define id-fed-test-count 0)
|
|
||||||
(define id-fed-test-pass 0)
|
|
||||||
(define id-fed-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-fed-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-fed-test-count (+ id-fed-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-fed-test-pass (+ id-fed-test-pass 1))
|
|
||||||
(append! id-fed-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idf-ev erlang-eval-ast)
|
|
||||||
(define idfnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-federation!)
|
|
||||||
|
|
||||||
;; ── trust gating ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-fed-test
|
|
||||||
"an assertion from an untrusted peer is rejected"
|
|
||||||
(idfnm
|
|
||||||
(idf-ev
|
|
||||||
"F = identity_federation:start(),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, _} -> accepted;\n {error, Why} -> Why\n end"))
|
|
||||||
"untrusted")
|
|
||||||
|
|
||||||
(id-fed-test
|
|
||||||
"a trusted peer's assertion is accepted"
|
|
||||||
(idfnm
|
|
||||||
(idf-ev
|
|
||||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, _} -> accepted;\n {error, Why} -> Why\n end"))
|
|
||||||
"accepted")
|
|
||||||
|
|
||||||
(id-fed-test
|
|
||||||
"untrust closes the door to future assertions"
|
|
||||||
(idfnm
|
|
||||||
(idf-ev
|
|
||||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:untrust(F, peer1),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, _} -> accepted;\n {error, Why} -> Why\n end"))
|
|
||||||
"untrusted")
|
|
||||||
|
|
||||||
(id-fed-test
|
|
||||||
"trusted? is true for a trusted peer"
|
|
||||||
(idfnm
|
|
||||||
(idf-ev
|
|
||||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n case identity_federation:trusted(F, peer1) of\n true -> yes;\n false -> no\n end"))
|
|
||||||
"yes")
|
|
||||||
|
|
||||||
(id-fed-test
|
|
||||||
"trusted? is false for an unknown peer"
|
|
||||||
(idfnm
|
|
||||||
(idf-ev
|
|
||||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n case identity_federation:trusted(F, peer2) of\n true -> yes;\n false -> no\n end"))
|
|
||||||
"no")
|
|
||||||
|
|
||||||
;; ── advisory provenance ──────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-fed-test
|
|
||||||
"an asserted identity is flagged peer_asserted with its origin"
|
|
||||||
(idfnm
|
|
||||||
(idf-ev
|
|
||||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n {ok, L} = identity_federation:assert_id(F, peer1, alice),\n case identity_federation:provenance(F, L) of\n {peer_asserted, P} -> P;\n {local} -> local\n end"))
|
|
||||||
"peer1")
|
|
||||||
|
|
||||||
(id-fed-test
|
|
||||||
"a non-federated subject has local provenance"
|
|
||||||
(idfnm
|
|
||||||
(idf-ev
|
|
||||||
"F = identity_federation:start(),\n case identity_federation:provenance(F, alice) of\n {peer_asserted, _} -> peer_asserted;\n {local} -> local\n end"))
|
|
||||||
"local")
|
|
||||||
|
|
||||||
;; ── cross-instance subject mapping ───────────────────────────────
|
|
||||||
|
|
||||||
(id-fed-test
|
|
||||||
"remote subjects are namespaced by peer by default"
|
|
||||||
(idfnm
|
|
||||||
(idf-ev
|
|
||||||
"F = identity_federation:start(),\n case identity_federation:resolve(F, peer1, alice) of\n {ok, {federated, _, Remote}} -> Remote;\n _ -> other\n end"))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
(id-fed-test
|
|
||||||
"the same remote name from two peers maps to distinct subjects"
|
|
||||||
(idfnm
|
|
||||||
(idf-ev
|
|
||||||
"F = identity_federation:start(),\n {ok, L1} = identity_federation:resolve(F, peer1, alice),\n {ok, L2} = identity_federation:resolve(F, peer2, alice),\n case L1 =:= L2 of\n true -> collision;\n false -> distinct\n end"))
|
|
||||||
"distinct")
|
|
||||||
|
|
||||||
(id-fed-test
|
|
||||||
"an explicit map aliases a remote subject to a local one"
|
|
||||||
(idfnm
|
|
||||||
(idf-ev
|
|
||||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:map(F, peer1, alice, alice_local),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, alice_local} -> mapped;\n {ok, _} -> unmapped;\n {error, W} -> W\n end"))
|
|
||||||
"mapped")
|
|
||||||
|
|
||||||
(id-fed-test
|
|
||||||
"a mapped subject keeps peer_asserted provenance"
|
|
||||||
(idfnm
|
|
||||||
(idf-ev
|
|
||||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:map(F, peer1, alice, alice_local),\n identity_federation:assert_id(F, peer1, alice),\n case identity_federation:provenance(F, alice_local) of\n {peer_asserted, P} -> P;\n {local} -> local\n end"))
|
|
||||||
"peer1")
|
|
||||||
|
|
||||||
(id-fed-test
|
|
||||||
"two peers asserting same name keep separate provenance"
|
|
||||||
(idfnm
|
|
||||||
(idf-ev
|
|
||||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:trust(F, peer2),\n {ok, L1} = identity_federation:assert_id(F, peer1, alice),\n {ok, _L2} = identity_federation:assert_id(F, peer2, alice),\n case identity_federation:provenance(F, L1) of\n {peer_asserted, P} -> P;\n {local} -> local\n end"))
|
|
||||||
"peer1")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-fed-test-summary
|
|
||||||
(str "federation " id-fed-test-pass "/" id-fed-test-count))
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
;; identity/tests/grants.sx — the client-credentials grant (RFC 6749
|
|
||||||
;; §4.4): a confidential client authenticates and gets a token acting on
|
|
||||||
;; its own behalf — no end-user, no refresh token (§4.4.3). Public clients
|
|
||||||
;; cannot use it.
|
|
||||||
|
|
||||||
(define id-grants-test-count 0)
|
|
||||||
(define id-grants-test-pass 0)
|
|
||||||
(define id-grants-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-grants-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-grants-test-count (+ id-grants-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-grants-test-pass (+ id-grants-test-pass 1))
|
|
||||||
(append! id-grants-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idg-ev erlang-eval-ast)
|
|
||||||
(define idgnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-oauth!)
|
|
||||||
|
|
||||||
;; ── confidential client-credentials happy path ───────────────────
|
|
||||||
|
|
||||||
(id-grants-test
|
|
||||||
"a confidential client obtains a working token"
|
|
||||||
(idgnm
|
|
||||||
(idg-ev
|
|
||||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, batch),\n case identity_oauth:introspect(O, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-grants-test
|
|
||||||
"the client-credentials token's subject is the client itself"
|
|
||||||
(idgnm
|
|
||||||
(idg-ev
|
|
||||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, batch),\n case identity_oauth:introspect(O, T) of\n {active, Subject, _, _} -> Subject\n end"))
|
|
||||||
"svc")
|
|
||||||
|
|
||||||
(id-grants-test
|
|
||||||
"the client-credentials token carries the requested scope"
|
|
||||||
(idgnm
|
|
||||||
(idg-ev
|
|
||||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, reports),\n case identity_oauth:introspect(O, T) of\n {active, _, _, Scope} -> Scope\n end"))
|
|
||||||
"reports")
|
|
||||||
|
|
||||||
(id-grants-test
|
|
||||||
"client-credentials issues no refresh token (single value)"
|
|
||||||
(idgnm
|
|
||||||
(idg-ev
|
|
||||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n case identity_oauth:client_credentials(O, svc, sk, batch) of\n {ok, _, _} -> pair;\n {ok, _} -> single;\n {error, W} -> W\n end"))
|
|
||||||
"single")
|
|
||||||
|
|
||||||
;; ── authentication failures ──────────────────────────────────────
|
|
||||||
|
|
||||||
(id-grants-test
|
|
||||||
"a wrong client secret is invalid_client"
|
|
||||||
(idgnm
|
|
||||||
(idg-ev
|
|
||||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n case identity_oauth:client_credentials(O, svc, wrong, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
|
|
||||||
"invalid_client")
|
|
||||||
|
|
||||||
(id-grants-test
|
|
||||||
"a public client cannot use client-credentials"
|
|
||||||
(idgnm
|
|
||||||
(idg-ev
|
|
||||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, spa, public, none, [uri1]),\n case identity_oauth:client_credentials(O, spa, none, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
|
|
||||||
"unauthorized_client")
|
|
||||||
|
|
||||||
(id-grants-test
|
|
||||||
"an unregistered client cannot use client-credentials"
|
|
||||||
(idgnm
|
|
||||||
(idg-ev
|
|
||||||
"O = identity_oauth:start(),\n case identity_oauth:client_credentials(O, ghost, x, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
|
|
||||||
"invalid_client")
|
|
||||||
|
|
||||||
;; ── independence + real revocation for client tokens ─────────────
|
|
||||||
|
|
||||||
(id-grants-test
|
|
||||||
"two confidential clients get independent tokens"
|
|
||||||
(idgnm
|
|
||||||
(idg-ev
|
|
||||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc1, confidential, k1, [uri1]),\n identity_oauth:register_client(O, svc2, confidential, k2, [uri1]),\n {ok, _T1} = identity_oauth:client_credentials(O, svc1, k1, batch),\n {ok, T2} = identity_oauth:client_credentials(O, svc2, k2, batch),\n case identity_oauth:introspect(O, T2) of\n {active, Subject, _, _} -> Subject\n end"))
|
|
||||||
"svc2")
|
|
||||||
|
|
||||||
(id-grants-test
|
|
||||||
"a client-credentials token can be revoked"
|
|
||||||
(idgnm
|
|
||||||
(idg-ev
|
|
||||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, batch),\n identity_oauth:revoke(O, T),\n case identity_oauth:introspect(O, T) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-grants-test-summary
|
|
||||||
(str "grants " id-grants-test-pass "/" id-grants-test-count))
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
;; identity/tests/introspect.sx — RFC 7662 §2.2 full introspection metadata
|
|
||||||
;; (sub, client_id, scope, exp, iat, token_type) alongside the live-lookup
|
|
||||||
;; active/inactive semantics.
|
|
||||||
|
|
||||||
(define id-intr-test-count 0)
|
|
||||||
(define id-intr-test-pass 0)
|
|
||||||
(define id-intr-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-intr-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-intr-test-count (+ id-intr-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-intr-test-pass (+ id-intr-test-pass 1))
|
|
||||||
(append! id-intr-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idi-ev erlang-eval-ast)
|
|
||||||
(define idinm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-token!)
|
|
||||||
|
|
||||||
;; ── metadata fields ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-intr-test
|
|
||||||
"introspect_full reports token_type bearer"
|
|
||||||
(idinm
|
|
||||||
(idi-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, _, _, Tt} -> Tt;\n {inactive} -> inactive\n end"))
|
|
||||||
"bearer")
|
|
||||||
|
|
||||||
(id-intr-test
|
|
||||||
"introspect_full reports the subject"
|
|
||||||
(idinm
|
|
||||||
(idi-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, Sub, _, _, _, _, _} -> Sub\n end"))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
(id-intr-test
|
|
||||||
"introspect_full reports the client_id"
|
|
||||||
(idinm
|
|
||||||
(idi-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, mobile, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, Cl, _, _, _, _} -> Cl\n end"))
|
|
||||||
"mobile")
|
|
||||||
|
|
||||||
(id-intr-test
|
|
||||||
"introspect_full reports the scope"
|
|
||||||
(idinm
|
|
||||||
(idi-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, write, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, Sc, _, _, _} -> Sc\n end"))
|
|
||||||
"write")
|
|
||||||
|
|
||||||
;; ── exp / iat reflect the logical clock ──────────────────────────
|
|
||||||
|
|
||||||
(id-intr-test
|
|
||||||
"iat is the clock value at issue"
|
|
||||||
(idi-ev
|
|
||||||
"R = identity_tokens:start(),\n identity_tokens:advance(R, 7),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, _, Iat, _} -> Iat\n end")
|
|
||||||
7)
|
|
||||||
|
|
||||||
(id-intr-test
|
|
||||||
"exp is iat plus the ttl"
|
|
||||||
(idi-ev
|
|
||||||
"R = identity_tokens:start(),\n identity_tokens:advance(R, 7),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, Exp, Iat, _} -> Exp - Iat\n end")
|
|
||||||
100)
|
|
||||||
|
|
||||||
;; ── inactive / expired / revoked ─────────────────────────────────
|
|
||||||
|
|
||||||
(id-intr-test
|
|
||||||
"an expired token introspects inactive in full mode too"
|
|
||||||
(idinm
|
|
||||||
(idi-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
(id-intr-test
|
|
||||||
"a revoked token introspects inactive in full mode"
|
|
||||||
(idinm
|
|
||||||
(idi-ev
|
|
||||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:revoke(R, T),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
(id-intr-test
|
|
||||||
"an unknown token introspects inactive in full mode"
|
|
||||||
(idinm
|
|
||||||
(idi-ev
|
|
||||||
"R = identity_tokens:start(),\n Bogus = make_ref(),\n case identity_tokens:introspect_full(R, Bogus) of\n {active, _, _, _, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-intr-test-summary
|
|
||||||
(str "introspect " id-intr-test-pass "/" id-intr-test-count))
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
;; identity/tests/membership.sx — membership state machine + per-app
|
|
||||||
;; grant projection. Valid transitions advance state; invalid ones are
|
|
||||||
;; explicit errors. The projection renders one canonical state per app.
|
|
||||||
|
|
||||||
(define id-membership-test-count 0)
|
|
||||||
(define id-membership-test-pass 0)
|
|
||||||
(define id-membership-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-membership-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-membership-test-count (+ id-membership-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-membership-test-pass (+ id-membership-test-pass 1))
|
|
||||||
(append! id-membership-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idm-ev erlang-eval-ast)
|
|
||||||
(define idmnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-membership!)
|
|
||||||
|
|
||||||
;; ── request → pending → approve → active ─────────────────────────
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"request leaves the subject pending"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
|
|
||||||
"pending")
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"approve activates a pending membership"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"status keeps the requested tier"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, supporter),\n identity_membership:approve(M, alice),\n case identity_membership:status(M, alice) of\n {ok, _, Tier} -> Tier\n end"))
|
|
||||||
"supporter")
|
|
||||||
|
|
||||||
;; ── guarded transitions: invalid moves are explicit errors ───────
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"requesting twice is an error"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:request(M, alice, basic) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
|
||||||
"exists")
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"approving an unknown subject is not_found"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n case identity_membership:approve(M, ghost) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
|
||||||
"not_found")
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"approving an already-active membership is an error"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:approve(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
;; ── lapse / reinstate ────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"active member can lapse"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end"))
|
|
||||||
"lapsed")
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"lapsing a pending membership is an error"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:lapse(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
|
||||||
"pending")
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"lapsed member can reinstate to active"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n identity_membership:reinstate(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
;; ── revoke is terminal ───────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"any member can be revoked"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end"))
|
|
||||||
"revoked")
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"a revoked membership cannot be reinstated"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:reinstate(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
|
||||||
"revoked")
|
|
||||||
|
|
||||||
;; ── per-app grant projection ─────────────────────────────────────
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"active member projects as member"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:project(M, alice, blog) of\n {member, _, _} -> member;\n {Tag, _} -> Tag\n end"))
|
|
||||||
"member")
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"projection carries the requesting app"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:project(M, alice, market) of\n {member, _, App} -> App\n end"))
|
|
||||||
"market")
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"the same subject projects consistently across apps"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, supporter),\n identity_membership:approve(M, alice),\n {member, T1, blog} = identity_membership:project(M, alice, blog),\n {member, T2, events} = identity_membership:project(M, alice, events),\n case T1 =:= T2 of\n true -> T1;\n false -> mismatch\n end"))
|
|
||||||
"supporter")
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"unknown subject projects as non_member"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n case identity_membership:project(M, ghost, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end"))
|
|
||||||
"non_member")
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"lapsed member projects as lapsed"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n case identity_membership:project(M, alice, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end"))
|
|
||||||
"lapsed")
|
|
||||||
|
|
||||||
(id-membership-test
|
|
||||||
"revoked member projects as denied"
|
|
||||||
(idmnm
|
|
||||||
(idm-ev
|
|
||||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:project(M, alice, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end"))
|
|
||||||
"denied")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-membership-test-summary
|
|
||||||
(str "membership " id-membership-test-pass "/" id-membership-test-count))
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
;; identity/tests/oauth.sx — OAuth2 authorization-code flow (RFC 6749
|
|
||||||
;; §4.1) + PKCE (RFC 7636) + refresh grant (§6). Covers the full happy
|
|
||||||
;; path end-to-end (code exchange → access+refresh → refresh rotation) and
|
|
||||||
;; every rejection: denied consent, single-use codes, client/redirect
|
|
||||||
;; binding, PKCE mismatch, unknown code/request, refresh-token reuse, and
|
|
||||||
;; revoke-then-use (which must fail).
|
|
||||||
|
|
||||||
(define id-oauth-test-count 0)
|
|
||||||
(define id-oauth-test-pass 0)
|
|
||||||
(define id-oauth-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-oauth-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-oauth-test-count (+ id-oauth-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-oauth-test-pass (+ id-oauth-test-pass 1))
|
|
||||||
(append! id-oauth-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define ido-ev erlang-eval-ast)
|
|
||||||
(define idonm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-token!)
|
|
||||||
(identity-load-oauth!)
|
|
||||||
|
|
||||||
;; Shared prelude: authorize + consent(allow) leaving Code bound.
|
|
||||||
(define
|
|
||||||
ido-granted
|
|
||||||
"O = identity_oauth:start(),\n {consent_required, ReqId} =\n identity_oauth:authorize(O, webapp, uri1, read, alice, verif1),\n {code, Code} = identity_oauth:consent(O, ReqId, allow)")
|
|
||||||
|
|
||||||
;; ── full happy path ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"authorize asks for consent"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
"O = identity_oauth:start(),\n case identity_oauth:authorize(O, webapp, uri1, read, alice, verif1) of\n {consent_required, _} -> consent_required;\n Other -> Other\n end"))
|
|
||||||
"consent_required")
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"consent(allow) returns a code"
|
|
||||||
(idonm (ido-ev (str ido-granted ", case Code of _ -> issued end")))
|
|
||||||
"issued")
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"exchanged access token introspects active"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
(str
|
|
||||||
ido-granted
|
|
||||||
", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"exchanged token carries the authorized subject"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
(str
|
|
||||||
ido-granted
|
|
||||||
", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, Subject, _, _} -> Subject\n end")))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"exchanged token carries the authorized scope"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
(str
|
|
||||||
ido-granted
|
|
||||||
", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, Scope} -> Scope\n end")))
|
|
||||||
"read")
|
|
||||||
|
|
||||||
;; ── refresh grant (RFC 6749 §6) end-to-end ───────────────────────
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"refresh after exchange yields a working access token"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
(str
|
|
||||||
ido-granted
|
|
||||||
", {ok, _A, R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n {ok, A2, _R2} = identity_oauth:refresh(O, R),\n case identity_oauth:introspect(O, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"reusing a rotated refresh token is invalid_grant"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
(str
|
|
||||||
ido-granted
|
|
||||||
", {ok, _A, R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n {ok, _A2, _R2} = identity_oauth:refresh(O, R),\n case identity_oauth:refresh(O, R) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end")))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
;; ── consent denied (§4.1.2.1) ────────────────────────────────────
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"denied consent yields access_denied"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
"O = identity_oauth:start(),\n {consent_required, ReqId} =\n identity_oauth:authorize(O, webapp, uri1, read, alice, verif1),\n case identity_oauth:consent(O, ReqId, deny) of\n {error, Why} -> Why;\n {code, _} -> issued\n end"))
|
|
||||||
"access_denied")
|
|
||||||
|
|
||||||
;; ── single-use codes (§10.5) ─────────────────────────────────────
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"code cannot be exchanged twice"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
(str
|
|
||||||
ido-granted
|
|
||||||
", identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:exchange(O, Code, webapp, uri1, verif1) of\n {ok, _, _} -> replayed;\n {error, Why} -> Why\n end")))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
;; ── code binding to client + redirect_uri (§4.1.3) ───────────────
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"exchange with wrong client is invalid_grant"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
(str
|
|
||||||
ido-granted
|
|
||||||
", case identity_oauth:exchange(O, Code, attacker, uri1, verif1) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end")))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"exchange with wrong redirect_uri is invalid_grant"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
(str
|
|
||||||
ido-granted
|
|
||||||
", case identity_oauth:exchange(O, Code, webapp, evil_uri, verif1) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end")))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
;; ── PKCE verifier mismatch (RFC 7636) ────────────────────────────
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"exchange with wrong PKCE verifier is invalid_grant"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
(str
|
|
||||||
ido-granted
|
|
||||||
", case identity_oauth:exchange(O, Code, webapp, uri1, badverif) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end")))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
;; ── unknown code / request ───────────────────────────────────────
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"exchanging an unknown code is invalid_grant"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
"O = identity_oauth:start(),\n Bogus = make_ref(),\n case identity_oauth:exchange(O, Bogus, webapp, uri1, verif1) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end"))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"consent on an unknown request is unknown_request"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
"O = identity_oauth:start(),\n Bogus = make_ref(),\n case identity_oauth:consent(O, Bogus, allow) of\n {code, _} -> issued;\n {error, Why} -> Why\n end"))
|
|
||||||
"unknown_request")
|
|
||||||
|
|
||||||
;; ── revoke-then-use must fail (RFC 7009) ─────────────────────────
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"revoked exchanged token introspects inactive"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
(str
|
|
||||||
ido-granted
|
|
||||||
", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n identity_oauth:revoke(O, Tok),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end")))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"revoking the access token blocks a later refresh (cascade)"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
(str
|
|
||||||
ido-granted
|
|
||||||
", {ok, A, R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n identity_oauth:revoke(O, A),\n case identity_oauth:refresh(O, R) of\n {ok, _, _} -> refreshed;\n {error, Why} -> Why\n end")))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
;; ── independence: two concurrent authorizations don't collide ────
|
|
||||||
|
|
||||||
(id-oauth-test
|
|
||||||
"two authorizations issue independent grants"
|
|
||||||
(idonm
|
|
||||||
(ido-ev
|
|
||||||
"O = identity_oauth:start(),\n {consent_required, R1} =\n identity_oauth:authorize(O, webapp, uri1, read, alice, va),\n {consent_required, R2} =\n identity_oauth:authorize(O, cli, uri2, write, bob, vb),\n {code, C1} = identity_oauth:consent(O, R1, allow),\n {code, C2} = identity_oauth:consent(O, R2, allow),\n {ok, _A1, _RR1} = identity_oauth:exchange(O, C1, webapp, uri1, va),\n {ok, A2, _RR2} = identity_oauth:exchange(O, C2, cli, uri2, vb),\n case identity_oauth:introspect(O, A2) of\n {active, Subject, _, _} -> Subject\n end"))
|
|
||||||
"bob")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-oauth-test-summary
|
|
||||||
(str "oauth " id-oauth-test-pass "/" id-oauth-test-count))
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
;; identity/tests/par.sx — pushed authorization requests (PAR, RFC 9126):
|
|
||||||
;; lodge the authorization params up front under a single-use request_uri,
|
|
||||||
;; then redeem it into the normal consent flow. The binding (client,
|
|
||||||
;; redirect, PKCE) carried by the pushed request is enforced at exchange.
|
|
||||||
|
|
||||||
(define id-par-test-count 0)
|
|
||||||
(define id-par-test-pass 0)
|
|
||||||
(define id-par-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-par-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-par-test-count (+ id-par-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-par-test-pass (+ id-par-test-pass 1))
|
|
||||||
(append! id-par-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idp-ev erlang-eval-ast)
|
|
||||||
(define idpnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-oauth!)
|
|
||||||
|
|
||||||
;; ── pushed request redeems into consent ──────────────────────────
|
|
||||||
|
|
||||||
(id-par-test
|
|
||||||
"authorize_pushed on a fresh request_uri asks for consent"
|
|
||||||
(idpnm
|
|
||||||
(idp-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n case identity_oauth:authorize_pushed(O, Ru) of\n {consent_required, _} -> consent_required;\n {error, W} -> W\n end"))
|
|
||||||
"consent_required")
|
|
||||||
|
|
||||||
;; ── full PAR flow ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-par-test
|
|
||||||
"the full PAR flow yields a working token"
|
|
||||||
(idpnm
|
|
||||||
(idp-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-par-test
|
|
||||||
"the PAR token carries the pushed subject"
|
|
||||||
(idpnm
|
|
||||||
(idp-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v),\n case identity_oauth:introspect(O, A) of\n {active, Subject, _, _} -> Subject\n end"))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
;; ── request_uri is single-use ────────────────────────────────────
|
|
||||||
|
|
||||||
(id-par-test
|
|
||||||
"a request_uri cannot be redeemed twice"
|
|
||||||
(idpnm
|
|
||||||
(idp-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n identity_oauth:authorize_pushed(O, Ru),\n case identity_oauth:authorize_pushed(O, Ru) of\n {consent_required, _} -> reused;\n {error, W} -> W\n end"))
|
|
||||||
"invalid_request_uri")
|
|
||||||
|
|
||||||
(id-par-test
|
|
||||||
"an unknown request_uri is rejected"
|
|
||||||
(idpnm
|
|
||||||
(idp-ev
|
|
||||||
"O = identity_oauth:start(),\n Bogus = make_ref(),\n case identity_oauth:authorize_pushed(O, Bogus) of\n {consent_required, _} -> ok;\n {error, W} -> W\n end"))
|
|
||||||
"invalid_request_uri")
|
|
||||||
|
|
||||||
;; ── the pushed binding is still enforced at exchange ─────────────
|
|
||||||
|
|
||||||
(id-par-test
|
|
||||||
"a PAR-issued code still enforces PKCE"
|
|
||||||
(idpnm
|
|
||||||
(idp-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n case identity_oauth:exchange(O, Cd, web, uri1, wrongverif) of\n {ok, _, _} -> ok;\n {error, W} -> W\n end"))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
(id-par-test
|
|
||||||
"a PAR-issued code still enforces client binding"
|
|
||||||
(idpnm
|
|
||||||
(idp-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n case identity_oauth:exchange(O, Cd, attacker, uri1, v) of\n {ok, _, _} -> ok;\n {error, W} -> W\n end"))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-par-test-summary
|
|
||||||
(str "par " id-par-test-pass "/" id-par-test-count))
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
;; 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))
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
;; identity/tests/session.sx — session-as-process: create, lookup,
|
|
||||||
;; touch, explicit expire, revoke, and idle-timeout self-expiry.
|
|
||||||
;; Negative paths are tested as first-class: a tombstoned session
|
|
||||||
;; answers {error, Status}, it does not go silent.
|
|
||||||
|
|
||||||
(define id-session-test-count 0)
|
|
||||||
(define id-session-test-pass 0)
|
|
||||||
(define id-session-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-session-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-session-test-count (+ id-session-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-session-test-pass (+ id-session-test-pass 1))
|
|
||||||
(append! id-session-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define id-ev erlang-eval-ast)
|
|
||||||
(define idnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-session!)
|
|
||||||
|
|
||||||
;; ── create + lookup ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-session-test
|
|
||||||
"lookup of live session is active"
|
|
||||||
(idnm
|
|
||||||
(id-ev
|
|
||||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n case identity_session:lookup(S) of {ok, {_,_,_,St}} -> St end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-session-test
|
|
||||||
"lookup preserves subject"
|
|
||||||
(idnm
|
|
||||||
(id-ev
|
|
||||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n case identity_session:lookup(S) of {ok, {_,Subject,_,_}} -> Subject end"))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
(id-session-test
|
|
||||||
"lookup preserves client"
|
|
||||||
(idnm
|
|
||||||
(id-ev
|
|
||||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n case identity_session:lookup(S) of {ok, {_,_,Client,_}} -> Client end"))
|
|
||||||
"web")
|
|
||||||
|
|
||||||
;; ── touch keeps a live session ───────────────────────────────────
|
|
||||||
|
|
||||||
(id-session-test
|
|
||||||
"touch on live session is ok"
|
|
||||||
(idnm
|
|
||||||
(id-ev
|
|
||||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:touch(S)"))
|
|
||||||
"ok")
|
|
||||||
|
|
||||||
;; ── explicit expire ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-session-test
|
|
||||||
"expire then lookup is error expired"
|
|
||||||
(idnm
|
|
||||||
(id-ev
|
|
||||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:expire(S),\n case identity_session:lookup(S) of {error, St} -> St end"))
|
|
||||||
"expired")
|
|
||||||
|
|
||||||
(id-session-test
|
|
||||||
"touch on expired session is error"
|
|
||||||
(idnm
|
|
||||||
(id-ev
|
|
||||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:expire(S),\n case identity_session:touch(S) of {error, St} -> St end"))
|
|
||||||
"expired")
|
|
||||||
|
|
||||||
;; ── revoke is immediate ──────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-session-test
|
|
||||||
"revoke then lookup is error revoked"
|
|
||||||
(idnm
|
|
||||||
(id-ev
|
|
||||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:revoke(S),\n case identity_session:lookup(S) of {error, St} -> St end"))
|
|
||||||
"revoked")
|
|
||||||
|
|
||||||
;; ── idle-timeout self-expiry ─────────────────────────────────────
|
|
||||||
|
|
||||||
(id-session-test
|
|
||||||
"idle timeout notifies owner"
|
|
||||||
(idnm
|
|
||||||
(id-ev
|
|
||||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, 50),\n _ = identity_session:lookup(S),\n receive {session_expired, Sid} -> Sid end"))
|
|
||||||
"s1")
|
|
||||||
|
|
||||||
(id-session-test
|
|
||||||
"lookup after idle timeout is error expired"
|
|
||||||
(idnm
|
|
||||||
(id-ev
|
|
||||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, 50),\n _ = identity_session:lookup(S),\n receive {session_expired, _} -> ok end,\n case identity_session:lookup(S) of {error, St} -> St end"))
|
|
||||||
"expired")
|
|
||||||
|
|
||||||
;; ── isolation: sessions are independent processes ────────────────
|
|
||||||
|
|
||||||
(id-session-test
|
|
||||||
"expiring one session leaves the other active"
|
|
||||||
(idnm
|
|
||||||
(id-ev
|
|
||||||
"Me = self(),\n A = identity_session:start(s1, alice, web, Me, infinity),\n B = identity_session:start(s2, bob, web, Me, infinity),\n identity_session:expire(A),\n case identity_session:lookup(B) of {ok, {_,_,_,St}} -> St end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
;; ── clean stop ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-session-test
|
|
||||||
"stop returns ok"
|
|
||||||
(idnm
|
|
||||||
(id-ev
|
|
||||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:stop(S)"))
|
|
||||||
"ok")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-session-test-summary
|
|
||||||
(str "session " id-session-test-pass "/" id-session-test-count))
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
;; identity/tests/session_mgmt.sx — subject-wide session management:
|
|
||||||
;; enumerate a subject's sessions and \"log out everywhere\".
|
|
||||||
|
|
||||||
(define id-smgmt-test-count 0)
|
|
||||||
(define id-smgmt-test-pass 0)
|
|
||||||
(define id-smgmt-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-smgmt-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-smgmt-test-count (+ id-smgmt-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-smgmt-test-pass (+ id-smgmt-test-pass 1))
|
|
||||||
(append! id-smgmt-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idsm-ev erlang-eval-ast)
|
|
||||||
(define idsmnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-all!)
|
|
||||||
|
|
||||||
;; ── enumerate a subject's sessions ───────────────────────────────
|
|
||||||
|
|
||||||
(id-smgmt-test
|
|
||||||
"sessions lists all of a subject's sessions"
|
|
||||||
(idsm-ev
|
|
||||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, cli, read),\n length(identity:sessions(Svc, alice))")
|
|
||||||
2)
|
|
||||||
|
|
||||||
(id-smgmt-test
|
|
||||||
"sessions is empty for a subject with none"
|
|
||||||
(idsm-ev
|
|
||||||
"Svc = identity:start(),\n length(identity:sessions(Svc, stranger))")
|
|
||||||
0)
|
|
||||||
|
|
||||||
;; ── log out everywhere ───────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-smgmt-test
|
|
||||||
"logout_all ends every session of the subject"
|
|
||||||
(idsmnm
|
|
||||||
(idsm-ev
|
|
||||||
"Svc = identity:start(),\n {ok, S1, _} = identity:login(Svc, alice, web, read),\n {ok, S2, _} = identity:login(Svc, alice, cli, read),\n identity:logout_all(Svc, alice),\n case {identity:session_status(Svc, S1), identity:session_status(Svc, S2)} of\n {gone, gone} -> both_gone;\n _ -> some_left\n end"))
|
|
||||||
"both_gone")
|
|
||||||
|
|
||||||
(id-smgmt-test
|
|
||||||
"after logout_all the subject has no sessions"
|
|
||||||
(idsm-ev
|
|
||||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, cli, read),\n identity:logout_all(Svc, alice),\n length(identity:sessions(Svc, alice))")
|
|
||||||
0)
|
|
||||||
|
|
||||||
(id-smgmt-test
|
|
||||||
"logout_all leaves other subjects' sessions intact"
|
|
||||||
(idsm-ev
|
|
||||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, web, read),\n identity:logout_all(Svc, alice),\n length(identity:sessions(Svc, bob))")
|
|
||||||
1)
|
|
||||||
|
|
||||||
(id-smgmt-test
|
|
||||||
"logout_all on an unknown subject is ok, not a crash"
|
|
||||||
(idsmnm
|
|
||||||
(idsm-ev "Svc = identity:start(),\n identity:logout_all(Svc, ghost)"))
|
|
||||||
"ok")
|
|
||||||
|
|
||||||
;; ── logout_all is audited ────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-smgmt-test
|
|
||||||
"logout_all records a logout event"
|
|
||||||
(idsmnm
|
|
||||||
(idsm-ev
|
|
||||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:logout_all(Svc, alice),\n case identity:history(Svc, alice) of\n [login, issue, logout] -> audited;\n Other -> Other\n end"))
|
|
||||||
"audited")
|
|
||||||
|
|
||||||
(id-smgmt-test
|
|
||||||
"logout_all audits each of several sessions"
|
|
||||||
(idsm-ev
|
|
||||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, cli, read),\n identity:logout_all(Svc, alice),\n length(identity:history(Svc, alice))")
|
|
||||||
6)
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-smgmt-test-summary
|
|
||||||
(str "session-mgmt " id-smgmt-test-pass "/" id-smgmt-test-count))
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
;; identity/tests/sso.sx — silent SSO (prompt=none, OIDC §3.1.2.1) as a
|
|
||||||
;; fast-path through the authorization-code machine. One subject session,
|
|
||||||
;; many client apps; no session → login_required (a negative state, not a
|
|
||||||
;; redirect). Silently-issued codes carry the same client/redirect/PKCE
|
|
||||||
;; binding as consented codes.
|
|
||||||
|
|
||||||
(define id-sso-test-count 0)
|
|
||||||
(define id-sso-test-pass 0)
|
|
||||||
(define id-sso-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-sso-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-sso-test-count (+ id-sso-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-sso-test-pass (+ id-sso-test-pass 1))
|
|
||||||
(append! id-sso-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define ids-ev erlang-eval-ast)
|
|
||||||
(define idsnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-token!)
|
|
||||||
(identity-load-session!)
|
|
||||||
(identity-load-registry!)
|
|
||||||
(identity-load-oauth!)
|
|
||||||
|
|
||||||
;; ── no session → login_required ──────────────────────────────────
|
|
||||||
|
|
||||||
(id-sso-test
|
|
||||||
"silent authorize without a session is login_required"
|
|
||||||
(idsnm
|
|
||||||
(ids-ev
|
|
||||||
"O = identity_oauth:start(),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
|
|
||||||
"login_required")
|
|
||||||
|
|
||||||
;; ── established session → silent code ────────────────────────────
|
|
||||||
|
|
||||||
(id-sso-test
|
|
||||||
"silent authorize for the same client returns a code"
|
|
||||||
(idsnm
|
|
||||||
(ids-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n case identity_oauth:silent_authorize(O, web, uri1, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
|
|
||||||
"got_code")
|
|
||||||
|
|
||||||
;; ── one session, many clients ────────────────────────────────────
|
|
||||||
|
|
||||||
(id-sso-test
|
|
||||||
"a different client gets a silent code off the same session"
|
|
||||||
(idsnm
|
|
||||||
(ids-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
|
|
||||||
"got_code")
|
|
||||||
|
|
||||||
(id-sso-test
|
|
||||||
"many clients all silently authorize off one session"
|
|
||||||
(idsnm
|
|
||||||
(ids-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, _C1} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n {code, _C2} = identity_oauth:silent_authorize(O, mobile, uri3, read, alice, vv),\n case identity_oauth:silent_authorize(O, billing, uri4, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
|
|
||||||
"got_code")
|
|
||||||
|
|
||||||
;; ── full SSO → token ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
(id-sso-test
|
|
||||||
"silent code exchanges to a working token"
|
|
||||||
(idsnm
|
|
||||||
(ids-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n {ok, A, _R} = identity_oauth:exchange(O, C, dashboard, uri2, vv),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-sso-test
|
|
||||||
"SSO token carries the subject"
|
|
||||||
(idsnm
|
|
||||||
(ids-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n {ok, A, _R} = identity_oauth:exchange(O, C, dashboard, uri2, vv),\n case identity_oauth:introspect(O, A) of\n {active, Subject, _, _} -> Subject\n end"))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
;; ── silent codes keep the full binding ───────────────────────────
|
|
||||||
|
|
||||||
(id-sso-test
|
|
||||||
"silent code still enforces PKCE at exchange"
|
|
||||||
(idsnm
|
|
||||||
(ids-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n case identity_oauth:exchange(O, C, dashboard, uri2, wrongverif) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end"))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
(id-sso-test
|
|
||||||
"silent code still enforces client binding at exchange"
|
|
||||||
(idsnm
|
|
||||||
(ids-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n case identity_oauth:exchange(O, C, attacker, uri2, vv) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end"))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
;; ── subject scoping: SSO is per subject ──────────────────────────
|
|
||||||
|
|
||||||
(id-sso-test
|
|
||||||
"another subject is still login_required"
|
|
||||||
(idsnm
|
|
||||||
(ids-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, bob, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
|
|
||||||
"login_required")
|
|
||||||
|
|
||||||
;; ── ending the session closes the SSO fast-path ──────────────────
|
|
||||||
|
|
||||||
(id-sso-test
|
|
||||||
"after end_session, silent authorize is login_required"
|
|
||||||
(idsnm
|
|
||||||
(ids-ev
|
|
||||||
"O = identity_oauth:start(),\n {ok, Sid} = identity_oauth:establish(O, alice, web),\n identity_oauth:end_session(O, Sid),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
|
|
||||||
"login_required")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-sso-test-summary
|
|
||||||
(str "sso " id-sso-test-pass "/" id-sso-test-count))
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
;; identity/tests/token.sx — opaque tokens, grant-backed lookup, real
|
|
||||||
;; revocation, refresh-token rotation, cascading revocation, and scope
|
|
||||||
;; narrowing on refresh. The revoke-then-introspect and refresh-reuse
|
|
||||||
;; paths are the security centrepieces.
|
|
||||||
|
|
||||||
(define id-token-test-count 0)
|
|
||||||
(define id-token-test-pass 0)
|
|
||||||
(define id-token-test-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-token-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(set! id-token-test-count (+ id-token-test-count 1))
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! id-token-test-pass (+ id-token-test-pass 1))
|
|
||||||
(append! id-token-test-fails {:name name :expected expected :actual actual}))))
|
|
||||||
|
|
||||||
(define idt-ev erlang-eval-ast)
|
|
||||||
(define idtnm (fn (v) (get v :name)))
|
|
||||||
|
|
||||||
(identity-load-token!)
|
|
||||||
|
|
||||||
;; ── issue + introspect (happy path) ──────────────────────────────
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"fresh token introspects active"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"introspect returns the granted subject"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, Tok) of\n {active, Subject, _, _} -> Subject\n end"))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"introspect returns the granted scope"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, write),\n case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, Scope} -> Scope\n end"))
|
|
||||||
"write")
|
|
||||||
|
|
||||||
;; ── opacity: distinct tokens, no cross-talk ──────────────────────
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"two issues yield independent grants"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(Reg, alice, web, read),\n {ok, B} = identity_tokens:issue(Reg, bob, cli, write),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:introspect(Reg, B) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
|
|
||||||
"bob")
|
|
||||||
|
|
||||||
;; ── revocation is real (RFC 7009) ────────────────────────────────
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"revoked token introspects inactive immediately"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n active = case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, _} -> active end,\n identity_tokens:revoke(Reg, Tok),\n case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"revoke is idempotent"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n identity_tokens:revoke(Reg, Tok),\n identity_tokens:revoke(Reg, Tok)"))
|
|
||||||
"ok")
|
|
||||||
|
|
||||||
;; ── unknown tokens are inactive, never an error/crash ────────────
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"introspecting an unknown token is inactive"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n Bogus = make_ref(),\n case identity_tokens:introspect(Reg, Bogus) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"revoking an unknown token is ok, not a crash"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n Bogus = make_ref(),\n identity_tokens:revoke(Reg, Bogus)"))
|
|
||||||
"ok")
|
|
||||||
|
|
||||||
;; ── one revocation does not affect a sibling token ───────────────
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"revoking one token leaves the other active"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(Reg, alice, web, read),\n {ok, B} = identity_tokens:issue(Reg, alice, cli, read),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:introspect(Reg, B) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
;; ── issue_grant: access + refresh pair (RFC 6749 §4.1.4 / §5.1) ───
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"issue_grant access token introspects active"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, A, _R} = identity_tokens:issue_grant(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
;; ── refresh rotation (RFC 6749 §6) ───────────────────────────────
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"refresh mints a working new access token"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"rotated token keeps the grant's subject"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, Subject, _, _} -> Subject\n end"))
|
|
||||||
"alice")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"refresh chains across rotations"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, _A2, R2} = identity_tokens:refresh(Reg, R),\n {ok, A3, _R3} = identity_tokens:refresh(Reg, R2),\n case identity_tokens:introspect(Reg, A3) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"active")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"refreshing an unknown token is invalid_grant"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n Bogus = make_ref(),\n case identity_tokens:refresh(Reg, Bogus) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end"))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
;; ── refresh-token reuse = theft → revoke the family (RFC 6819) ────
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"reusing a superseded refresh token is invalid_grant"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, _A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:refresh(Reg, R) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end"))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"refresh reuse revokes the live descendant too"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
;; ── cascading revocation: revoke any token, the grant dies ───────
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"revoking the access token blocks refresh"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:refresh(Reg, R) of\n {ok, _, _} -> refreshed;\n {error, Why} -> Why\n end"))
|
|
||||||
"invalid_grant")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"revoking the refresh token deactivates the access token"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:revoke(Reg, R),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
;; ── scope as a set + narrowing on refresh (RFC 6749 §6 / §3.3) ───
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"a list scope round-trips through introspect"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, A, _R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, [read, write]} -> matched;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))
|
|
||||||
"matched")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"refresh can narrow the scope to a subset"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R, [read]),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, [read]} -> narrowed;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))
|
|
||||||
"narrowed")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"refresh cannot widen scope beyond the grant"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read]),\n case identity_tokens:refresh(Reg, R, [read, write]) of\n {ok, _, _} -> widened;\n {error, Why} -> Why\n end"))
|
|
||||||
"invalid_scope")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"an invalid_scope refresh does not consume the refresh token"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n identity_tokens:refresh(Reg, R, [admin]),\n case identity_tokens:refresh(Reg, R, [read]) of\n {ok, _, _} -> still_usable;\n {error, Why} -> Why\n end"))
|
|
||||||
"still_usable")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"plain refresh keeps the full grant scope"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, [read, write]} -> full;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))
|
|
||||||
"full")
|
|
||||||
|
|
||||||
(id-token-test
|
|
||||||
"a narrowed token still cascades on revoke"
|
|
||||||
(idtnm
|
|
||||||
(idt-ev
|
|
||||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R, [read]),\n identity_tokens:revoke(Reg, A2),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
|
||||||
"inactive")
|
|
||||||
|
|
||||||
(define
|
|
||||||
id-token-test-summary
|
|
||||||
(str "token " id-token-test-pass "/" id-token-test-count))
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher
|
|||||||
|
|
||||||
## Status (rolling)
|
## Status (rolling)
|
||||||
|
|
||||||
`bash lib/events/conformance.sh` → **0/0** (not yet started)
|
`bash lib/events/conformance.sh` → **219/219** (Phases 1-4 + ext: federated free/busy)
|
||||||
|
|
||||||
## Ground rules
|
## Ground rules
|
||||||
|
|
||||||
@@ -54,28 +54,148 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Phase 1 — Calendar + recurrence
|
## Phase 1 — Calendar + recurrence
|
||||||
- [ ] `calendar.sx` — event facts, RRULE expansion in a window
|
- [x] `calendar.sx` — event facts, RRULE expansion in a window (DAILY/WEEKLY)
|
||||||
- [ ] `availability.sx` — free/busy rules
|
- [x] `calendar.sx` — MONTHLY (bymonthday + nth-weekday byday)
|
||||||
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
- [x] `availability.sx` — free/busy rules (busy/conflict/busy_in on Datalog)
|
||||||
|
- [x] `availability.sx` — next-free slot search (same rules, different bindings)
|
||||||
|
- [x] `api.sx` — public entry points (schedule/agenda/book/free/next-free/conflicts)
|
||||||
|
- [x] tests + scoreboard + conformance.sh (73/73)
|
||||||
|
|
||||||
## Phase 2 — Ticketing + booking
|
## Phase 2 — Ticketing + booking
|
||||||
- [ ] capacity rules; transactional booking → `persist` (no overbooking)
|
- [x] capacity rules; transactional booking → `persist` (no overbooking)
|
||||||
- [ ] paid tickets compose with `commerce` order flow
|
- [x] wire `booking.sx` into `api.sx` (persist-backed `ev/book-occ!` + derived availability)
|
||||||
- [ ] tests: capacity edge, double-book guard, conflict detection
|
- [x] cancellation (tombstone events) + seat release
|
||||||
|
- [x] provisional holds (hold/confirm/release) — reserve a seat during pending payment
|
||||||
|
- [x] paid tickets compose with `commerce` order flow (contract module over holds)
|
||||||
|
- [x] tests: capacity edge, double-book guard, conflict detection
|
||||||
|
|
||||||
## Phase 3 — Notification delivery (flow)
|
## Phase 3 — Notification delivery (flow)
|
||||||
- [ ] `notify.sx` — reminder/digest flows over injected transport
|
- [x] `notify.sx` — reminder/digest flows over injected transport
|
||||||
- [ ] retry/backoff on transport failure (flow suspend/resume)
|
- [x] retry/backoff on transport failure (flow suspend/resume)
|
||||||
- [ ] tests: delivery success, retry path, idempotent re-send
|
- [x] tests: delivery success, retry path, idempotent re-send
|
||||||
|
- [x] wire reminders to occurrences (`reminders.sx` — derive from agenda + roster)
|
||||||
- [ ] NOTE: shared with `feed/notify` — candidate for later extraction to a
|
- [ ] NOTE: shared with `feed/notify` — candidate for later extraction to a
|
||||||
`delivery-on-sx` once a second consumer is real
|
`delivery-on-sx` once a second consumer is real. **Delivery core
|
||||||
|
(request→dispatch→resume, idempotent, bounded retry) is the extraction seam.**
|
||||||
|
|
||||||
## Phase 4 — Federation
|
## Phase 4 — Federation
|
||||||
- [ ] cross-instance events (peer calendar) — trust-gated stub
|
- [x] cross-instance events (peer calendar) — trust-gated stub
|
||||||
- [ ] tests: federated agenda merge
|
- [x] tests: federated agenda merge
|
||||||
|
- [x] federated availability/free-busy across trusted peers
|
||||||
|
- [ ] real transport behind `ev/peer-agenda` (fed-sx / signed fetch)
|
||||||
|
|
||||||
## Progress log
|
## Progress log
|
||||||
(loop fills this in)
|
|
||||||
|
- 2026-06-07 — Federated free/busy (extension). Peers publish BUSY intervals
|
||||||
|
per actor (iCal free/busy model — privacy-preserving, not event details).
|
||||||
|
`ev/peer-with-busy`, `ev/peer-busy`; `ev/federated-busy` unions local
|
||||||
|
availability-db busy + trusted peers' published busy (sorted);
|
||||||
|
`ev/federated-free?` answers "is X free in [qs,qe)?" across instances,
|
||||||
|
half-open, trust-gated (untrusted peers' busy ignored; revocation immediate).
|
||||||
|
+10 tests, 219/219 green.
|
||||||
|
- 2026-06-07 — **Phase 4: federation (trust-gated stub).** `federation.sx`:
|
||||||
|
a peer publishes a schedule (events store); `ev/federated-agenda` merges the
|
||||||
|
local agenda (origin :local) with every TRUSTED peer's agenda, sorted by
|
||||||
|
start, each occurrence tagged with :origin provenance. Trust is a peer-id set
|
||||||
|
re-checked per merge (revocation is immediate); untrusted peers contribute
|
||||||
|
nothing. `ev/peer`, `ev/trusts?`, `ev/trusted-peers`, `ev/peer-agenda`
|
||||||
|
(expands the peer's recurrence in-window), `ev/from-origin` (filter by
|
||||||
|
source). Real transport slots behind `ev/peer-agenda` unchanged. +13 tests,
|
||||||
|
**209/209 green — all four plan phases implemented.**
|
||||||
|
- 2026-06-07 — Reminders + digests from the agenda. `reminders.sx` bridges
|
||||||
|
calendar + durable rosters to notify: `ev/occurrence-reminders` (one per
|
||||||
|
booked attendee, fires `lead` before start, idempotency key
|
||||||
|
occ-key/recipient/lead), `ev/agenda-reminders` (window-wide, sorted by
|
||||||
|
fire-at), `ev/due-reminders` (fire-at ≤ now — the scheduler query),
|
||||||
|
`ev/reminder->msg` (projects to notify's (id recipient body) shape),
|
||||||
|
`ev/agenda-digest` + `ev/agenda-for-p` (an actor's upcoming booked
|
||||||
|
occurrences). +14 tests, 196/196 green.
|
||||||
|
- 2026-06-07 — **Phase 3 start: notification delivery flows.** `notify.sx`:
|
||||||
|
reminders + digests as durable `flow`s over an INJECTED transport (the host
|
||||||
|
`dispatch`). A flow `request`s delivery (suspend), the host sends and resumes
|
||||||
|
with the outcome; flow's replay log means a completed send is never re-run on
|
||||||
|
recovery. At-least-once + idempotent: messages carry an id; the transport
|
||||||
|
dedups (re-send is a no-op that still reports ok) and replay logs each
|
||||||
|
outcome. Retry rides suspend/resume — each attempt uses a DISTINCT tag
|
||||||
|
`(deliver <id> <n>)` so replay stays correct; dispatch returns (ok) /
|
||||||
|
(retry reason), bounded by maxn → (failed id reason). Digest delivers a batch
|
||||||
|
with independent per-message outcomes. Authored as Scheme flow source run via
|
||||||
|
`ev/notify-run` (scheme + flow substrate preloaded). +7 tests, 182/182 green.
|
||||||
|
Delivery core is the `delivery-on-sx` extraction seam for feed/notify.
|
||||||
|
- 2026-06-07 — **Phase 2 complete: paid-ticket contract.** `ticket.sx` defines
|
||||||
|
the two wire messages between events and commerce — `checkout-request`
|
||||||
|
(events→commerce) and `payment-result` (commerce→events, :paid/:failed/
|
||||||
|
:expired) — so commerce imports the contract, not vice versa. Orchestration
|
||||||
|
over holds: `ev/request-ticket!` places a capacity-safe hold + emits a
|
||||||
|
checkout-request; `ev/settle-payment!` confirms on :paid, releases on
|
||||||
|
failure/expiry. Idempotent (redelivered :paid stays confirmed, redelivered
|
||||||
|
release is :noop); a late :paid for a vanished hold → :paid-but-no-hold
|
||||||
|
(refund signal), no phantom seat. occ-key+actor locate the hold so no side
|
||||||
|
table. +31 tests, 175/175 green. Phase 3 (notification flows) is next.
|
||||||
|
- 2026-06-07 — Provisional holds (paid-ticket foundation). Booking stream now
|
||||||
|
carries :booking/:hold/:confirm/:release/:cancel; the fold tracks per-actor
|
||||||
|
seat STATE (:held / :confirmed). A held seat counts toward capacity, so a
|
||||||
|
pending payment cannot be oversold. `ev/hold!` (capacity-safe, retrying),
|
||||||
|
`ev/confirm!` (held→confirmed), `ev/release!` (frees a held seat only),
|
||||||
|
`ev/seat-state`. Seat-acquiring writes (:booking/:hold) go through
|
||||||
|
append-expect; seat-freeing writes (:cancel/:release) and :confirm append
|
||||||
|
directly (never oversell). Holds race test mirrors the booking race. +24
|
||||||
|
tests, 144/144 green. Next: ticket.sx contract module over holds.
|
||||||
|
- 2026-06-07 — Wired `booking.sx` into `api.sx`: durable persist-backed booking
|
||||||
|
path alongside the in-memory one. `ev/book-occ!`, `ev/cancel-occ!`,
|
||||||
|
`ev/roster-occ`, `ev/seats-left-occ` (capacity from the scheduled event);
|
||||||
|
`ev/free-p?`, `ev/next-free-p`, `ev/conflicts-p` derive availability by
|
||||||
|
replaying persist booking streams for in-window occurrences. Capacity-safe +
|
||||||
|
cancellable bookings now flow through the public API. Reordered conformance
|
||||||
|
preloads (persist + booking before events/api). +10 tests, 120/120 green.
|
||||||
|
- 2026-06-07 — Cancellation + seat release. Booking stream now carries
|
||||||
|
:booking / :cancel events; the live roster is the FOLDED replay (book adds,
|
||||||
|
cancel removes) so capacity reopens when a seat is freed. `ev/cancel!`
|
||||||
|
(retrying, append-expect), `ev/cancel-with-observed`. Edge cases: cancelling
|
||||||
|
an unbooked actor → :not-booked (no-op), double cancel → :not-booked,
|
||||||
|
cancelled actor may re-book. Capacity count is the folded roster size, not
|
||||||
|
the physical event count. +13 tests, 110/110 green.
|
||||||
|
- 2026-06-07 — **Phase 2 start: capacity-safe booking.** `booking.sx`: one
|
||||||
|
append-only persist stream per occurrence; roster = stream replayed. Booking
|
||||||
|
decisions made against an OBSERVED (roster, last-seq) snapshot, committed via
|
||||||
|
`persist/append-expect` — append only if last-seq unchanged, else a conflict
|
||||||
|
value the booker retries. This makes capacity-check + append atomic at the
|
||||||
|
persist boundary: no overbooking, no lock. `ev/book!` (retrying),
|
||||||
|
`ev/book-with-observed`, `ev/roster`, `ev/seats-left`. Idempotent per actor
|
||||||
|
(:already). Explicit last-seat race test: two bookers on the same snapshot →
|
||||||
|
one :booked, one :conflict, roster never exceeds capacity; loser retry →
|
||||||
|
:full (or next seat when room remains). 24 tests, 97/97 green.
|
||||||
|
- 2026-06-07 — **Phase 1 complete.** `api.sx`: immutable `store`
|
||||||
|
({:events :bookings}) facade over calendar + availability. `ev/schedule`,
|
||||||
|
`ev/book`, `ev/agenda`, `ev/agenda-for`, `ev/free?`, `ev/next-free`,
|
||||||
|
`ev/conflicts`. Availability queries auto-widen the expansion window back by
|
||||||
|
the longest event so any overlapping occurrence is captured. 14 tests,
|
||||||
|
73/73 green. Phase 2 (transactional booking on persist) is next — `ev/book`
|
||||||
|
becomes capacity-safe via a persist append at that point.
|
||||||
|
- 2026-06-07 — `next-free` slot search: earliest start ≥ after where
|
||||||
|
[s,s+duration) is free and ends ≤ horizon, else nil. Candidates are `after`
|
||||||
|
plus each busy-interval end (interval-packing); each probe reuses the
|
||||||
|
`busy_in` Datalog rule via `ev-free?`. Finds gaps between bookings, skips
|
||||||
|
too-short gaps, half-open at edges. +6 tests, 59/59.
|
||||||
|
- 2026-06-07 — `availability.sx`: free/busy + conflict detection as forward-
|
||||||
|
chained Datalog over `occurrence`/`booking` EDB. Rules `busy(A,S,E)`,
|
||||||
|
`conflict(A,O1,O2)` (canonical `O1<O2`, half-open overlap `S1<E2 ∧ S2<E1`),
|
||||||
|
`busy_in(A,QS,QE)` for window queries. API: `ev-busy`, `ev-conflicts`,
|
||||||
|
`ev-has-conflict?`, `ev-free?` (transient qwindow assert/retract). Integrates
|
||||||
|
with calendar expansion (book expanded occurrences). 16 tests, 53/53 green.
|
||||||
|
- 2026-06-06 — MONTHLY recurrence. `ev-days-in-month`, `ev-add-months`,
|
||||||
|
BYMONTHDAY (incl. negative = from month end), ordinal BYDAY (`{:ord N :wd W}`,
|
||||||
|
ord<0 = nth-from-last), default day-of-month (skips months too short, e.g.
|
||||||
|
day-31 monthly skips Feb/Apr). Refactored weekly+monthly onto a shared
|
||||||
|
`ev-emit-occs` per-period emitter. 37/37 green (+13).
|
||||||
|
- 2026-06-06 — Phase 1 scaffold + calendar recurrence. `calendar.sx`: integer
|
||||||
|
epoch-minute datetimes, Hinnant civil<->day-number conversion, DAILY/WEEKLY
|
||||||
|
RRULE expansion in a bounded (start,end) window with INTERVAL, COUNT (window-
|
||||||
|
independent), UNTIL, BYDAY (weekly). `ev-expand-all` merges + sorts. Wired
|
||||||
|
conformance harness (conf + thin wrapper reusing `lib/guest/conformance.sh`),
|
||||||
|
scoreboard. 24/24 green. MONTHLY deferred to next commit.
|
||||||
|
|
||||||
## Blockers
|
## Blockers
|
||||||
(loop fills this in)
|
|
||||||
|
- None. Substrates present: `lib/datalog` (276/276), `lib/persist`, `lib/flow`
|
||||||
|
all exist — Phase 2/3 unblocked when reached.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ through the event log, all authorization questions delegated to `acl-on-sx`.
|
|||||||
|
|
||||||
## Status (rolling)
|
## Status (rolling)
|
||||||
|
|
||||||
`bash lib/identity/conformance.sh` → **229/229** (4 phases + 14 ext) — slow (~10min, run in background; internal timeout 1200)
|
`bash lib/identity/conformance.sh` → **0/0** (not yet started)
|
||||||
|
|
||||||
## Ground rules
|
## Ground rules
|
||||||
|
|
||||||
@@ -57,230 +57,28 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke)
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Phase 1 — Sessions + tokens
|
## Phase 1 — Sessions + tokens
|
||||||
- [x] `session.sx` — session process, create/lookup/expire
|
- [ ] `session.sx` — session process, create/lookup/expire
|
||||||
- [x] `token.sx` — issue/introspect/revoke (opaque, grant-backed)
|
- [ ] `token.sx` — issue/introspect/revoke (opaque, grant-backed)
|
||||||
- [x] `registry.sx` — route by subject/client
|
- [ ] `registry.sx` — route by subject/client
|
||||||
- [x] `api.sx` + tests + scoreboard + conformance.sh
|
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
||||||
|
|
||||||
## Phase 2 — OAuth2 flows
|
## Phase 2 — OAuth2 flows
|
||||||
- [x] authorization-code flow as a message protocol
|
- [ ] authorization-code flow as a message protocol
|
||||||
- [x] refresh + rotation; revocation cascades to issued tokens
|
- [ ] refresh + rotation; revocation cascades to issued tokens
|
||||||
- [x] tests: full code exchange, refresh, revoke-then-use (must fail)
|
- [ ] tests: full code exchange, refresh, revoke-then-use (must fail)
|
||||||
|
|
||||||
## Phase 3 — Silent SSO + membership
|
## Phase 3 — Silent SSO + membership
|
||||||
- [x] `prompt=none` cross-app login (one session, many clients)
|
- [ ] `prompt=none` cross-app login (one session, many clients)
|
||||||
- [x] membership state + per-app grant projection
|
- [ ] membership state + per-app grant projection
|
||||||
- [x] grant verification delegated cache (mirror Redis-cache pattern)
|
- [ ] grant verification delegated cache (mirror Redis-cache pattern)
|
||||||
|
|
||||||
## Phase 4 — Audit + federation
|
## Phase 4 — Audit + federation
|
||||||
- [x] every issue/refresh/revoke is a `persist` event; `(identity/audit subject)`
|
- [ ] every issue/refresh/revoke is a `persist` event; `(identity/audit subject)`
|
||||||
- [x] federated identity (peer-asserted subject) — advisory, trust-gated stub
|
- [ ] federated identity (peer-asserted subject) — advisory, trust-gated stub
|
||||||
- [x] tests: audit completeness, cross-instance subject mapping
|
- [ ] tests: audit completeness, cross-instance subject mapping
|
||||||
|
|
||||||
## Extensions (base roadmap complete; deepen the engine)
|
|
||||||
- [~] PKCE S256 method (RFC 7636 §4.2) — BLOCKED on erlang substrate (see Blockers)
|
|
||||||
- [x] access-token TTL / `expires_in` — logical-clock expiry, introspect honours it
|
|
||||||
- [x] scope as a set + scope narrowing on refresh (RFC 6749 §6)
|
|
||||||
- [x] client registry: public vs confidential clients, client authentication (RFC 6749 §2)
|
|
||||||
- [x] client-credentials grant (RFC 6749 §4.4) + device grant (RFC 8628)
|
|
||||||
- [x] acl-on-sx delegation: identity-gates-before-acl boundary (401 vs 403), stub decider (live Datalog bridge is cross-substrate)
|
|
||||||
- [~] OAuth `state`/OIDC `nonce` — low value in this server-centric model (client-side echo); skipped
|
|
||||||
- [x] pushed authorization requests (PAR, RFC 9126): single-use request_uri → consent
|
|
||||||
- [x] dynamic client registration (RFC 7591): server-generated client_id + secret
|
|
||||||
- [x] "apps with access": `grants_for(Subject)` / `identity:grants` (per-subject active grants)
|
|
||||||
- [x] unify `api.sx` over membership + audit (one facade, audited login/logout)
|
|
||||||
- [x] subject-wide session management: `sessions(Subject)` + `logout_all` (log out everywhere)
|
|
||||||
- [x] token exchange (RFC 8693): downscope a token into a new independent token
|
|
||||||
- [x] RFC 7662 full introspection metadata (`introspect_full`: sub/client_id/scope/exp/iat/token_type)
|
|
||||||
|
|
||||||
## Progress log
|
## Progress log
|
||||||
- 2026-06-07 — "apps with access" (ext): `identity_tokens:grants_for(Subject)`
|
(loop fills this in)
|
||||||
lists a subject's ACTIVE grants as `[{Client, Scope}]` (revoked excluded),
|
|
||||||
exposed through the facade as `identity:grants(Subject)`. Completes the
|
|
||||||
per-subject account-security trio: sessions (where), grants (which apps),
|
|
||||||
history (what happened). New tests/account.sx (7). 222→229. NOTE: conformance
|
|
||||||
is now slow (~10 min, 22 suites); run it in the background — internal
|
|
||||||
sx_server timeout raised to 1200s. The suite is at its monolithic-runtime
|
|
||||||
ceiling; further test growth should consider splitting the harness.
|
|
||||||
- 2026-06-07 — dynamic client registration (ext, RFC 7591): `register_dynamic`
|
|
||||||
generates a client_id + secret server-side (make_ref each) and registers the
|
|
||||||
client, returning {ok, ClientId, Secret} — self-service onboarding distinct
|
|
||||||
from the manual register_client. A dynamic confidential client can then use
|
|
||||||
client_credentials; a dynamic public client stays unauthorized_client. New
|
|
||||||
tests/dynreg.sx (5). 217→222.
|
|
||||||
- 2026-06-07 — PAR (ext, RFC 9126): `push_authorization_request` lodges the
|
|
||||||
authorization params under a single-use `request_uri`; `authorize_pushed`
|
|
||||||
redeems it into the normal consent flow. Pushed requests reuse the pending
|
|
||||||
store (`{pushed, Rec}` keyed by the request_uri ref — distinct from consent
|
|
||||||
req_ids, no collision), so no new loop state. The pushed binding (client +
|
|
||||||
redirect + PKCE) is enforced at exchange. New tests/par.sx (7). 210→217.
|
|
||||||
- 2026-06-07 — full introspection (ext, RFC 7662 §2.2): `introspect_full`
|
|
||||||
returns {active, Subject, Client, Scope, Exp, Iat, bearer} for live tokens,
|
|
||||||
{inactive} otherwise — deepening the opaque-token/live-lookup model the
|
|
||||||
whole design rests on. Access tokens now carry `Iat` (clock-at-issue);
|
|
||||||
exp = iat + ttl. Simple `introspect` unchanged. New tests/introspect.sx (9).
|
|
||||||
201→210. NOTE: conformance now needs an explicit long timeout (>120s, 19
|
|
||||||
suites) — run with `timeout 580`.
|
|
||||||
- 2026-06-07 — token exchange (ext, RFC 8693 §2.1): `oauth.sx` gains
|
|
||||||
`token_exchange(SubjectToken, RequestedScope)` — a valid access token is
|
|
||||||
downscoped into a NEW independent grant for the same subject (subset only,
|
|
||||||
else invalid_scope; inactive subject token → invalid_grant). The new token's
|
|
||||||
lifecycle is independent (revoking either leaves the other active);
|
|
||||||
exchanges chain. Least-privilege handoff to downstream services. New
|
|
||||||
tests/exchange.sx (8). 193→201.
|
|
||||||
- 2026-06-07 — subject-wide session management (ext): `api.sx` gains
|
|
||||||
`sessions(Subject)` (enumerate) and `logout_all(Subject)` ("log out
|
|
||||||
everywhere") — revokes + deregisters every session a subject holds,
|
|
||||||
auditing a logout per session, leaving other subjects untouched. Builds on
|
|
||||||
registry.sessions_for. New tests/session_mgmt.sx (8). 185→193.
|
|
||||||
- 2026-06-07 — `delegation.sx` (ext): the identity→acl boundary made concrete.
|
|
||||||
`check` introspects the token first: inactive → `{error, unauthenticated}`
|
|
||||||
(401, acl never consulted); active → constructs {Subject, Scope, Action,
|
|
||||||
Resource} and hands off to acl, which returns permit/deny (`forbidden` =
|
|
||||||
403). 401 strictly precedes 403 (a revoked token with no scope is still
|
|
||||||
unauthenticated). acl-on-sx (Datalog) is a different SX guest language —
|
|
||||||
wired at the integration layer — so the decider here is a labelled stub
|
|
||||||
(permits when Action ∈ Scope); swap the pid, boundary unchanged. New
|
|
||||||
tests/delegation.sx (8). 177→185. **Extensions backlog clear.**
|
|
||||||
- 2026-06-07 — unified facade (ext): `api.sx` coordinator now owns an audit
|
|
||||||
ledger + a membership registry alongside its token table (started with the
|
|
||||||
ledger) and session registry. login/logout are audited; new ops
|
|
||||||
`history`/`enroll`/`member_status`/`member_project` expose the audit +
|
|
||||||
membership axes through the one `identity` door. identity proves who +
|
|
||||||
reports membership; acl still decides permission. Existing api behaviour
|
|
||||||
unchanged (10/10). New tests/facade.sx (9). 168→177.
|
|
||||||
- 2026-06-07 — `device.sx` (ext, RFC 8628): device authorization grant for
|
|
||||||
input-constrained devices. authorize → {device_code, user_code}; the human
|
|
||||||
approve/deny out-of-band by user_code; the device polls by device_code
|
|
||||||
through the §3.5 status machine (authorization_pending → access_denied /
|
|
||||||
{ok,Token}). Device code is single-use once a token issues; guarded
|
|
||||||
transitions (approve-after-deny rejected). Tokens grant-backed. Device-code
|
|
||||||
expiry + slow_down deferred (no wall clock). New tests/device.sx (10). 158→168.
|
|
||||||
- 2026-06-07 — client-credentials grant (ext, RFC 6749 §4.4): `oauth.sx` now
|
|
||||||
owns a client registry (loop/6); `register_client` + `client_credentials`.
|
|
||||||
A confidential client authenticates and gets a token acting on its own
|
|
||||||
behalf (subject = the client), no refresh token (§4.4.3). A public client is
|
|
||||||
`unauthorized_client`; any auth failure (unknown client OR wrong secret) is
|
|
||||||
`invalid_client` — no client-existence oracle (§5.2). `identity-load-oauth!`
|
|
||||||
now pulls its deps (token/session/registry/clients). New tests/grants.sx (9).
|
|
||||||
149→158.
|
|
||||||
- 2026-06-07 — `clients.sx` (ext): OAuth client registry (RFC 6749 §2). public
|
|
||||||
vs confidential clients; confidential clients MUST present the right secret
|
|
||||||
(wrong → invalid_client), public clients are identified but not
|
|
||||||
authenticated; redirect_uris are allow-listed with exact-match
|
|
||||||
`valid_redirect` (§3.1.2.2 + Security BCP). Standalone module (no oauth
|
|
||||||
wiring yet — that's a follow-up). New tests/clients.sx (11). 138→149.
|
|
||||||
- 2026-06-07 — access-token expiry (ext): logical clock in the token registry
|
|
||||||
(`advance`/`now`; no wall clock in substrate). Grants carry a Ttl; each
|
|
||||||
access token carries an Expires (Now-at-issue + Ttl, or infinity); introspect
|
|
||||||
returns inactive once `Now` reaches it. Refresh mints a fresh short-lived
|
|
||||||
access token (new Expires) — short access tokens, long refresh tokens. issue/4
|
|
||||||
+ issue_grant/4 default to infinity, so all prior tests unchanged. New
|
|
||||||
tests/expiry.sx (8). token loop/6. 130→138.
|
|
||||||
- 2026-06-07 — scope narrowing (ext): each access token now carries its own
|
|
||||||
EFFECTIVE scope (<= the grant's max). `refresh/3` requests a narrower scope;
|
|
||||||
the request must be a subset of the grant scope (RFC 6749 §6) else
|
|
||||||
`{error, invalid_scope}` and the refresh token is NOT consumed (client may
|
|
||||||
retry, §5.2). `refresh/2` keeps full scope; scope stays opaque (atom or list)
|
|
||||||
for issue, so all prior atom-scope tests pass unchanged. token 18→24, 130/130.
|
|
||||||
Also filed Blocker: PKCE S256 needs SHA256+binary compare, both broken in the
|
|
||||||
erlang substrate (binary `=:=` always true; crypto:hash ignores binary
|
|
||||||
content) — deferred, plain method stays.
|
|
||||||
- 2026-06-07 — `federation.sx`: trust-gated, advisory federated identity.
|
|
||||||
A peer assertion is accepted only from an explicitly trusted peer
|
|
||||||
(else `{error, untrusted}`) and is flagged `{peer_asserted, Peer}`, never
|
|
||||||
promoted to local authority — acl decides what it may do. Cross-instance
|
|
||||||
subject mapping namespaces remote subjects by peer (`{federated, Peer,
|
|
||||||
Remote}`) so two peers' "alice" never collide, with optional explicit
|
|
||||||
aliasing. Added an audit-completeness test (mixed transition stream → no
|
|
||||||
event dropped). New tests/federation.sx (12). **Phase 4 complete — all four
|
|
||||||
phases done.** +13 → 124/124.
|
|
||||||
- 2026-06-07 — `audit.sx`: append-only grant audit ledger (an Erlang
|
|
||||||
process). `token.sx` gains `start/1(Audit)` and emits issue/refresh/revoke
|
|
||||||
events (incl. reuse-triggered revoke); `start/0` stays unaudited (no
|
|
||||||
regression — token.sx has no compile-time dep on the audit module, just
|
|
||||||
sends to a pid). Ledger queryable per subject — `audit`/`actions`/`count`/
|
|
||||||
`all`, chronological. In-memory event stream (persist-backing is a future
|
|
||||||
Erlang↔persist bridge, out of scope per loop allowance). New
|
|
||||||
tests/audit.sx (10). +10 → 111/111.
|
|
||||||
- 2026-06-07 — `cache.sx`: delegated grant-verification cache (Redis-cache
|
|
||||||
pattern) wrapping the token registry. introspect memoised; generation
|
|
||||||
invalidation keeps revocation real — any revoke/refresh bumps a generation
|
|
||||||
counter so every cached positive instantly becomes a miss and re-validates
|
|
||||||
against the live registry. A revoked token never reads valid from cache.
|
|
||||||
stats() exposes hits/misses. New tests/cache.sx (9). **Phase 3 complete.**
|
|
||||||
+9 → 101/101.
|
|
||||||
- 2026-06-07 — `membership.sx`: coop membership as a guarded state machine
|
|
||||||
(none→pending→active→lapsed⇄active, any→revoked terminal); invalid
|
|
||||||
transitions are explicit `{error, CurrentStatus}`. `project(Subject, App)`
|
|
||||||
renders the one canonical state into a per-app claim
|
|
||||||
({member,Tier,App}/{pending,App}/{lapsed,App}/{denied,App}/{non_member,App})
|
|
||||||
— identity reports what; acl decides whether. New tests/membership.sx (17).
|
|
||||||
+17 → 92/92.
|
|
||||||
- 2026-06-07 — silent SSO (`prompt=none`, OIDC §3.1.2.1): `oauth.sx` now owns
|
|
||||||
a session registry; `establish` creates a subject session, `silent_authorize`
|
|
||||||
asks "does this subject have a live session?" → mints a code (skipping
|
|
||||||
consent) bound to client+redirect+PKCE, else `login_required`. Same machine,
|
|
||||||
fast-path — one session, many clients; `end_session` closes the path.
|
|
||||||
New `tests/sso.sx` (10). +10 → 75/75.
|
|
||||||
- 2026-06-07 — `oauth.sx` refresh wiring + e2e: exchange now issues an
|
|
||||||
access+refresh pair (RFC 6749 §4.1.4/§5.1) via token.sx issue_grant; added
|
|
||||||
the refresh grant (§6) delegating to token rotation. End-to-end tests:
|
|
||||||
code-exchange→refresh→introspect, refresh-reuse rejected, and
|
|
||||||
revoke-then-refresh blocked by cascade. **Phase 2 complete.** +3 → oauth 17,
|
|
||||||
65/65.
|
|
||||||
- 2026-06-07 — `token.sx` grant-centric rewrite: refresh-token rotation
|
|
||||||
(RFC 6749 §6) + cascading revocation. The grant {Subject,Client,Scope,
|
|
||||||
Status} is the cascade unit; access + refresh tokens reference it.
|
|
||||||
`issue_grant` → {ok, Access, Refresh}; `refresh` supersedes the old
|
|
||||||
refresh + mints a new pair; reusing a superseded refresh token revokes
|
|
||||||
the whole family (RFC 6819 §5.2.2.3), killing the live descendant.
|
|
||||||
`revoke` of ANY token (access or refresh) cascades to the grant. All
|
|
||||||
prior issue/introspect/revoke behaviour preserved. +9 → token 18, 62/62.
|
|
||||||
- 2026-06-07 — `oauth.sx`: OAuth2 authorization-code flow as a message
|
|
||||||
protocol (RFC 6749 §4.1) + PKCE (RFC 7636, plain). State machine on one
|
|
||||||
authz-server process: authorize → {consent_required} → consent →
|
|
||||||
{code} → exchange → {ok, Token}. Exchange enforces single-use codes
|
|
||||||
(§10.5; removed on first attempt, replay → invalid_grant), client_id +
|
|
||||||
redirect_uri binding (§4.1.3), and PKCE verifier match. Issued tokens are
|
|
||||||
grant-backed so revocation stays real. +14 → 53/53.
|
|
||||||
- 2026-06-06 — `api.sx`: service facade. `identity:start()` spawns one
|
|
||||||
coordinator owning the token table + session registry; exposes
|
|
||||||
login/verify/revoke/logout/session_status. Coordinator is the sessions'
|
|
||||||
owner, so an expired session deregisters itself (timeout-driven, no
|
|
||||||
sweep). `verify` answers IDENTITY only ({active, Subject, Client, Scope});
|
|
||||||
permission is acl's job — explicit delegation boundary. **Phase 1 complete.**
|
|
||||||
+10 → 39/39.
|
|
||||||
- 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
|
|
||||||
`{inactive}` on the next introspection, no validity window. Reply shapes
|
|
||||||
follow RFC 7662 §2.2 (`{active,...}` / `{inactive}`, never says why). +9 → 20/20.
|
|
||||||
- 2026-06-06 — `session.sx`: session-as-Erlang-process. create/lookup/touch/
|
|
||||||
explicit-expire/revoke as messages; idle-timeout self-expiry via
|
|
||||||
`receive ... after Ttl` notifying the owner then tombstoning. Tombstones
|
|
||||||
answer lookups with `{error, expired|revoked}` — never a silent dead
|
|
||||||
mailbox. Established the conformance harness (`conformance.sh`, scoreboard,
|
|
||||||
`tests/session.sx`). 11/11.
|
|
||||||
|
|
||||||
## Blockers
|
## Blockers
|
||||||
- 2026-06-07 — **PKCE S256 blocked: erlang binary bugs.** Two substrate bugs
|
(loop fills this in)
|
||||||
in `lib/erlang` make a correct/secure S256 impossible (S256 needs
|
|
||||||
`BASE64URL(SHA256(verifier))` compared against the stored challenge):
|
|
||||||
1. **Binary `=:=` always true.** `<<"v1">> =:= <<"v2">>` → `true`;
|
|
||||||
`<<"abc">> =:= <<"abd">>` → `true`. So a hash comparison can't reject a
|
|
||||||
wrong verifier.
|
|
||||||
2. **`crypto:hash` ignores binary-literal content.**
|
|
||||||
`crypto:hash(sha256, <<"v1">>)` and `crypto:hash(sha256, <<"v2">>)` return
|
|
||||||
the *identical* 32-byte digest (`6e 34 0b 9c …`), which is also ≠ the
|
|
||||||
correct SX-level `(crypto-sha256 "abc")` (`ba 78 16 bf …`). The binary
|
|
||||||
payload isn't reaching the hash. (Atom input → badarg→nil, separate issue.)
|
|
||||||
Minimal repro (epoch protocol, after loading lib/erlang/runtime.sx):
|
|
||||||
`(erlang-eval-ast "case <<\"a\">> =:= <<\"b\">> of true -> bug; false -> ok end")`
|
|
||||||
→ `bug`. Not in scope to fix (lib/erlang is a substrate). PKCE `plain`
|
|
||||||
remains correct and in use; S256 deferred until the binary path is fixed.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user