Compare commits
27 Commits
loops/even
...
loops/iden
| Author | SHA1 | Date | |
|---|---|---|---|
| d466ca3414 | |||
| 3b782eba8a | |||
| 8130521f02 | |||
| 398209d484 | |||
| 3c3b09688a | |||
| ded7170540 | |||
| b1f9c6bef0 | |||
| db885e15bc | |||
| d2f5b49d3f | |||
| 226d755b57 | |||
| 3f3459d129 | |||
| 9860582b4a | |||
| a43825f25f | |||
| e951f23f14 | |||
| 21673b6731 | |||
| e448220b33 | |||
| a5c22c5a01 | |||
| 785faf2441 | |||
| dc00ed9786 | |||
| 56cf920041 | |||
| 20ba152e36 | |||
| baee67f561 | |||
| 27f43dbf10 | |||
| 064bbf18b3 | |||
| 938e90455d | |||
| ac63501266 | |||
| 1c6b80404e |
@@ -1 +1 @@
|
||||
{"sessionId":"c4d97db1-361c-4a04-a99b-c838f9385469","pid":2426590,"procStart":"349789073","acquiredAt":1780789990975}
|
||||
{"sessionId":"bf20a443-9df8-4cb9-932e-8c6f4c4625c2","pid":1303602,"procStart":"253831081","acquiredAt":1779865895644}
|
||||
@@ -1,329 +0,0 @@
|
||||
;; 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)))
|
||||
|
||||
;; ---- conflict-checked booking ----
|
||||
;; Capacity is per-event, but an attendee should not be double-booked against
|
||||
;; THEMSELVES across different events. Would booking `actor` into `occ` overlap
|
||||
;; an existing booking of theirs elsewhere? (Derived from persist availability;
|
||||
;; an existing booking into `occ` itself is excluded — that's idempotent.)
|
||||
(define
|
||||
ev/would-time-conflict?
|
||||
(fn
|
||||
(b store actor occ)
|
||||
(and
|
||||
(not (ev-actor-booked? b (ev-occ-key occ) actor))
|
||||
(not (ev/free-p? b store actor (get occ :start) (get occ :end))))))
|
||||
|
||||
;; Book `actor` into `occ` only if it doesn't clash with their other bookings.
|
||||
;; Re-booking the same occurrence is idempotent (:already); a clash returns
|
||||
;; :time-conflict; otherwise the normal ev/book-occ! result (:booked / :full).
|
||||
(define
|
||||
ev/book-checked!
|
||||
(fn
|
||||
(b store actor occ)
|
||||
(cond
|
||||
((ev-actor-booked? b (ev-occ-key occ) actor) (ev/book-occ! b store actor occ))
|
||||
((ev/would-time-conflict? b store actor occ)
|
||||
{:status :time-conflict :actor actor :occ-key (ev-occ-key occ)})
|
||||
(else (ev/book-occ! b store actor occ)))))
|
||||
|
||||
;; ---- whole-series operations ----
|
||||
;; Apply a booking action to every occurrence of one event in [ws, we) — e.g.
|
||||
;; "RSVP to the whole weekly class". Returns a list of (occ-key status) results,
|
||||
;; one per occurrence (empty if the event id is unknown).
|
||||
(define
|
||||
ev/book-series!
|
||||
(fn
|
||||
(b store actor event-id ws we)
|
||||
(let
|
||||
((ev (ev/event-by-id store event-id)))
|
||||
(if
|
||||
(nil? ev)
|
||||
(list)
|
||||
(map
|
||||
(fn (occ) (list (ev-occ-key occ) (get (ev/book-occ! b store actor occ) :status)))
|
||||
(ev-expand ev ws we))))))
|
||||
|
||||
;; Cancel `actor` from every occurrence of one event in [ws, we).
|
||||
(define
|
||||
ev/cancel-series!
|
||||
(fn
|
||||
(b store actor event-id ws we)
|
||||
(let
|
||||
((ev (ev/event-by-id store event-id)))
|
||||
(if
|
||||
(nil? ev)
|
||||
(list)
|
||||
(map
|
||||
(fn (occ) (list (ev-occ-key occ) (get (ev/cancel! b (ev-occ-key occ) actor) :status)))
|
||||
(ev-expand ev ws we))))))
|
||||
|
||||
;; How many statuses in a series-result list equal `status`.
|
||||
(define
|
||||
ev/series-count
|
||||
(fn
|
||||
(results status)
|
||||
(len (filter (fn (r) (= (first (rest r)) status)) results))))
|
||||
|
||||
;; The occurrences of one event in [ws, we) that `actor` is booked into.
|
||||
(define
|
||||
ev/series-booked
|
||||
(fn
|
||||
(b store actor event-id ws we)
|
||||
(let
|
||||
((ev (ev/event-by-id store event-id)))
|
||||
(if
|
||||
(nil? ev)
|
||||
(list)
|
||||
(filter
|
||||
(fn (occ) (ev-actor-booked? b (ev-occ-key occ) actor))
|
||||
(ev-expand ev ws we))))))
|
||||
@@ -1,177 +0,0 @@
|
||||
;; 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))))
|
||||
@@ -1,102 +0,0 @@
|
||||
;; lib/events/booking-notify.sx — derive lifecycle notifications from the
|
||||
;; booking stream, for delivery via notify.sx.
|
||||
;;
|
||||
;; Walking the append-only booking stream yields one notification per state
|
||||
;; change, in order, classified by kind:
|
||||
;;
|
||||
;; :booked a confirmed booking
|
||||
;; :promoted a booking for an actor who was on the waitlist (auto-promote)
|
||||
;; :held a provisional hold (pending payment)
|
||||
;; :confirmed a held seat became confirmed (payment succeeded)
|
||||
;; :released a held seat was released (payment failed/expired)
|
||||
;; :cancelled a seat was given up
|
||||
;; :waitlisted an actor joined the waitlist
|
||||
;;
|
||||
;; Promotion is detected by folding the waitlist as we walk: a :booking for an
|
||||
;; actor currently on the waitlist is a promotion, not a fresh booking.
|
||||
;;
|
||||
;; Each notification's id is occ-key/seq (the stream seq is unique and stable),
|
||||
;; so re-deriving and re-delivering is idempotent — the notify transport dedups
|
||||
;; on this id and never double-pings.
|
||||
|
||||
(define
|
||||
ev-bn-kind
|
||||
(fn
|
||||
(typ promoted?)
|
||||
(cond
|
||||
((= typ :hold) :held)
|
||||
((= typ :booking) (if promoted? :promoted :booked))
|
||||
((= typ :confirm) :confirmed)
|
||||
((= typ :cancel) :cancelled)
|
||||
((= typ :release) :released)
|
||||
((= typ :waitlist) :waitlisted)
|
||||
(else nil))))
|
||||
|
||||
(define
|
||||
ev-bn-update-waiting
|
||||
(fn
|
||||
(typ actor waiting)
|
||||
(cond
|
||||
((= typ :waitlist)
|
||||
(if
|
||||
(ev-bk-member? actor waiting)
|
||||
waiting
|
||||
(ev-bk-append waiting actor)))
|
||||
((= typ :unwaitlist) (ev-bk-remove waiting actor))
|
||||
((= typ :booking) (ev-bk-remove waiting actor))
|
||||
((= typ :hold) (ev-bk-remove waiting actor))
|
||||
(else waiting))))
|
||||
|
||||
(define ev-bn-mk (fn (occ-key label actor kind seq) {:id (str occ-key "/" seq) :event label :kind kind :recipient actor :seq seq}))
|
||||
|
||||
(define
|
||||
ev-bn-step
|
||||
(fn
|
||||
(occ-key label events waiting)
|
||||
(if
|
||||
(empty? events)
|
||||
(list)
|
||||
(let
|
||||
((e (first events)))
|
||||
(let
|
||||
((typ (persist/event-type e))
|
||||
(actor (get (persist/event-data e) :actor))
|
||||
(seq (persist/event-seq e)))
|
||||
(let
|
||||
((promoted? (and (= typ :booking) (ev-bk-member? actor waiting))))
|
||||
(let
|
||||
((kind (ev-bn-kind typ promoted?))
|
||||
(waiting2 (ev-bn-update-waiting typ actor waiting)))
|
||||
(if
|
||||
(nil? kind)
|
||||
(ev-bn-step occ-key label (rest events) waiting2)
|
||||
(cons
|
||||
(ev-bn-mk occ-key label actor kind seq)
|
||||
(ev-bn-step occ-key label (rest events) waiting2))))))))))
|
||||
|
||||
;; The ordered lifecycle notifications for an occurrence's bookings. `label` is
|
||||
;; a human-facing event id carried on each notification.
|
||||
(define
|
||||
ev/booking-notifications
|
||||
(fn
|
||||
(b occ-key label)
|
||||
(ev-bn-step
|
||||
occ-key
|
||||
label
|
||||
(persist/read b (ev-booking-stream occ-key))
|
||||
(list))))
|
||||
|
||||
;; Filter notifications to a single kind.
|
||||
(define
|
||||
ev/notify-of-kind
|
||||
(fn (notifs kind) (filter (fn (n) (= (get n :kind) kind)) notifs)))
|
||||
|
||||
;; Project a notification to notify.sx's (id recipient body) wire shape.
|
||||
(define
|
||||
ev/booking-notify->msg
|
||||
(fn
|
||||
(n)
|
||||
(list
|
||||
(get n :id)
|
||||
(get n :recipient)
|
||||
(list :booking-event (get n :kind) (get n :event)))))
|
||||
@@ -1,372 +0,0 @@
|
||||
;; 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))))))
|
||||
|
||||
(define ev-bk-append (fn (xs a) (append xs (list a))))
|
||||
(define ev-bk-remove (fn (xs a) (filter (fn (x) (not (= x a))) xs)))
|
||||
|
||||
;; ---- 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)))))
|
||||
|
||||
;; ---- waitlist ----
|
||||
;; When an occurrence is full, actors join a FIFO waitlist (:waitlist /
|
||||
;; :unwaitlist events on the same stream). Taking a seat (:booking / :hold)
|
||||
;; removes an actor from the queue, so the waitlist fold is independent of the
|
||||
;; seat fold. Cancelling/releasing a seat can auto-promote the head of the
|
||||
;; queue (a :booking appended for them).
|
||||
|
||||
(define
|
||||
ev-fold-waiting
|
||||
(fn
|
||||
(events)
|
||||
(reduce
|
||||
(fn
|
||||
(acc e)
|
||||
(let
|
||||
((typ (persist/event-type e))
|
||||
(actor (get (persist/event-data e) :actor)))
|
||||
(cond
|
||||
((= typ :waitlist) (if (ev-bk-member? actor acc) acc (ev-bk-append acc actor)))
|
||||
((= typ :unwaitlist) (ev-bk-remove acc actor))
|
||||
((= typ :booking) (ev-bk-remove acc actor))
|
||||
((= typ :hold) (ev-bk-remove acc actor))
|
||||
(else acc))))
|
||||
(list)
|
||||
events)))
|
||||
|
||||
;; The current waitlist queue (FIFO, oldest first).
|
||||
(define
|
||||
ev/waitlist
|
||||
(fn (b occ-key) (ev-fold-waiting (persist/read b (ev-booking-stream occ-key)))))
|
||||
|
||||
;; 1-based queue position for an actor (0 if not waiting).
|
||||
(define
|
||||
ev/waitlist-position
|
||||
(fn (b occ-key actor) (ev-seat-of (ev/waitlist b occ-key) actor)))
|
||||
|
||||
;; Book if a seat is free, else join the waitlist. Idempotent: already seated →
|
||||
;; :already; already queued → :already-waiting.
|
||||
(define
|
||||
ev/waitlist!
|
||||
(fn
|
||||
(b occ-key capacity actor)
|
||||
(let
|
||||
((seats (ev-booked-actors b occ-key))
|
||||
(waiting (ev/waitlist b occ-key)))
|
||||
(cond
|
||||
((ev-bk-member? actor seats)
|
||||
{:status :already :seat (ev-seat-of seats actor) :actor actor})
|
||||
((ev-bk-member? actor waiting)
|
||||
{:status :already-waiting :position (ev-seat-of waiting actor) :actor actor})
|
||||
(else
|
||||
(let
|
||||
((r (ev/book! b occ-key capacity actor)))
|
||||
(if
|
||||
(= (get r :status) :booked)
|
||||
r
|
||||
(begin
|
||||
(persist/append b (ev-booking-stream occ-key) :waitlist 0 {:actor actor})
|
||||
{:status :waitlisted
|
||||
:position (+ (len waiting) 1)
|
||||
:actor actor}))))))))
|
||||
|
||||
;; Leave the waitlist. :left or :not-waiting.
|
||||
(define
|
||||
ev/leave-waitlist!
|
||||
(fn
|
||||
(b occ-key actor)
|
||||
(if
|
||||
(ev-bk-member? actor (ev/waitlist b occ-key))
|
||||
(begin
|
||||
(persist/append b (ev-booking-stream occ-key) :unwaitlist 0 {:actor actor})
|
||||
{:status :left :actor actor})
|
||||
{:status :not-waiting :actor actor})))
|
||||
|
||||
;; Cancel a seat and, if that frees capacity, auto-promote the head of the
|
||||
;; waitlist (a confirmed booking). Returns the cancel result plus :promoted
|
||||
;; (the actor promoted, or nil).
|
||||
(define
|
||||
ev/cancel-promote!
|
||||
(fn
|
||||
(b occ-key capacity actor)
|
||||
(let
|
||||
((c (ev/cancel! b occ-key actor)))
|
||||
(if
|
||||
(= (get c :status) :cancelled)
|
||||
(let
|
||||
((waiting (ev/waitlist b occ-key))
|
||||
(seats (ev-booked-actors b occ-key)))
|
||||
(if
|
||||
(and (not (empty? waiting)) (< (len seats) capacity))
|
||||
(let
|
||||
((promoted (first waiting)))
|
||||
(begin
|
||||
(persist/append b (ev-booking-stream occ-key) :booking 0 {:actor promoted})
|
||||
{:status :cancelled :actor actor :promoted promoted}))
|
||||
{:status :cancelled :actor actor :promoted nil}))
|
||||
c))))
|
||||
@@ -1,614 +0,0 @@
|
||||
;; 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}))
|
||||
|
||||
;; Event with EXDATE/RDATE exceptions. exdate/rdate are lists of epoch-minute
|
||||
;; starts to exclude from / add to the expansion (RFC 5545 VEVENT properties).
|
||||
(define
|
||||
ev-event-full
|
||||
(fn
|
||||
(id dtstart duration rrule capacity exdate rdate)
|
||||
{:duration duration
|
||||
:id id
|
||||
:dtstart dtstart
|
||||
:capacity capacity
|
||||
:rrule rrule
|
||||
:exdate exdate
|
||||
:rdate rdate}))
|
||||
|
||||
(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 ----
|
||||
;; Raw expansion (RRULE / single event), before EXDATE/RDATE are applied.
|
||||
;; Returns a list of occurrence dicts {:id :start :end} within the window.
|
||||
(define
|
||||
ev-expand-base
|
||||
(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-base: unsupported freq: " freq))))
|
||||
acc))))))
|
||||
|
||||
;; ---- EXDATE / RDATE (RFC 5545 exceptions) ----
|
||||
;; Applied AFTER raw expansion: RDATE adds explicit occurrences within the
|
||||
;; window, EXDATE removes occurrences whose start matches (EXDATE wins over
|
||||
;; RDATE). Both are VEVENT-level: (get event :exdate) / (get event :rdate) are
|
||||
;; lists of epoch-minute starts; nil for plain events.
|
||||
|
||||
(define
|
||||
ev-num-member?
|
||||
(fn
|
||||
(n xs)
|
||||
(cond
|
||||
((empty? xs) false)
|
||||
((= n (first xs)) true)
|
||||
(else (ev-num-member? n (rest xs))))))
|
||||
|
||||
;; Drop duplicate-start occurrences from a start-sorted list (keep one).
|
||||
(define
|
||||
ev-dedupe-by-start
|
||||
(fn
|
||||
(occs)
|
||||
(cond
|
||||
((empty? occs) occs)
|
||||
((empty? (rest occs)) occs)
|
||||
((= (get (first occs) :start) (get (first (rest occs)) :start))
|
||||
(ev-dedupe-by-start (rest occs)))
|
||||
(else (cons (first occs) (ev-dedupe-by-start (rest occs)))))))
|
||||
|
||||
(define
|
||||
ev-apply-exceptions
|
||||
(fn
|
||||
(event base win-start win-end)
|
||||
(let
|
||||
((id (get event :id))
|
||||
(duration (get event :duration))
|
||||
(exdate (ev-or (get event :exdate) (list)))
|
||||
(rdate (ev-or (get event :rdate) (list))))
|
||||
(let
|
||||
((rdate-occs
|
||||
(reduce
|
||||
(fn
|
||||
(acc d)
|
||||
(if
|
||||
(and (>= d win-start) (<= d win-end))
|
||||
(cons (ev-occ id d duration) acc)
|
||||
acc))
|
||||
(list)
|
||||
rdate)))
|
||||
(let
|
||||
((no-ex
|
||||
(filter
|
||||
(fn (o) (not (ev-num-member? (get o :start) exdate)))
|
||||
(append base rdate-occs))))
|
||||
(ev-dedupe-by-start (ev-sort-occs no-ex)))))))
|
||||
|
||||
;; ---- per-occurrence overrides (RFC 5545 RECURRENCE-ID) ----
|
||||
;; A single instance of a recurring series can be detached and rescheduled. The
|
||||
;; event carries :overrides — a list of (orig-start {:start :duration}) — keyed
|
||||
;; by the occurrence's ORIGINAL start. Applied after EXDATE/RDATE. A moved
|
||||
;; instance whose new start leaves the window is dropped from this window (the
|
||||
;; original slot is vacated); an instance moved INTO the window from outside is
|
||||
;; out of scope for a windowed expansion (known stub limitation).
|
||||
|
||||
(define
|
||||
ev-assoc-lookup
|
||||
(fn
|
||||
(k pairs)
|
||||
(cond
|
||||
((empty? pairs) nil)
|
||||
((= (first (first pairs)) k) (first (rest (first pairs))))
|
||||
(else (ev-assoc-lookup k (rest pairs))))))
|
||||
|
||||
(define
|
||||
ev-apply-overrides
|
||||
(fn
|
||||
(id base overrides)
|
||||
(map
|
||||
(fn
|
||||
(o)
|
||||
(let
|
||||
((ov (ev-assoc-lookup (get o :start) overrides)))
|
||||
(if (nil? ov) o (ev-occ id (get ov :start) (get ov :duration)))))
|
||||
base)))
|
||||
|
||||
;; Add an override that reschedules the occurrence originally at `orig-start`
|
||||
;; to `new-start` with `new-duration`.
|
||||
(define
|
||||
ev-with-override
|
||||
(fn
|
||||
(event orig-start new-start new-duration)
|
||||
(assoc
|
||||
event
|
||||
:overrides
|
||||
(cons
|
||||
(list orig-start {:start new-start :duration new-duration})
|
||||
(ev-or (get event :overrides) (list))))))
|
||||
|
||||
;; Naive (single time-domain) expansion: RRULE + EXDATE/RDATE + overrides.
|
||||
(define
|
||||
ev-expand-naive
|
||||
(fn
|
||||
(event win-start win-end)
|
||||
(let
|
||||
((excepted
|
||||
(ev-apply-exceptions
|
||||
event
|
||||
(ev-expand-base event win-start win-end)
|
||||
win-start
|
||||
win-end))
|
||||
(overrides (ev-or (get event :overrides) (list)))
|
||||
(id (get event :id)))
|
||||
(if
|
||||
(empty? overrides)
|
||||
excepted
|
||||
(filter
|
||||
(fn (o) (and (>= (get o :start) win-start) (<= (get o :start) win-end)))
|
||||
(ev-sort-occs (ev-apply-overrides id excepted overrides)))))))
|
||||
|
||||
;; Public entry point. A tz-aware event (`:tz` set) expands in local wall-clock
|
||||
;; time and converts each occurrence to UTC (ev-expand-tz, timezone.sx); a plain
|
||||
;; event expands naively in a single time domain. The window is UTC either way.
|
||||
(define
|
||||
ev-expand
|
||||
(fn
|
||||
(event win-start win-end)
|
||||
(let
|
||||
((tz (get event :tz)))
|
||||
(if
|
||||
(nil? tz)
|
||||
(ev-expand-naive event win-start win-end)
|
||||
(ev-expand-tz event tz win-start win-end)))))
|
||||
|
||||
;; ---- 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)))))
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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/timezone.sx
|
||||
lib/events/ical.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/booking-notify.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!)"
|
||||
"timezone:lib/events/tests/timezone.sx:(ev-timezone-tests-run!)"
|
||||
"ical:lib/events/tests/ical.sx:(ev-ical-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!)"
|
||||
"booking-notify:lib/events/tests/booking-notify.sx:(ev-booking-notify-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!)"
|
||||
"integration:lib/events/tests/integration.sx:(ev-integration-tests-run!)"
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/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" "$@"
|
||||
@@ -1,232 +0,0 @@
|
||||
;; 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)))))
|
||||
|
||||
;; ---- injected transport (real fed-sx / signed fetch) ----
|
||||
;; The in-process merge above expands a peer's local :store directly. In
|
||||
;; production a peer's agenda arrives over a transport. `fetch` abstracts that:
|
||||
;; (fetch peer-id ws we) -> {:status :ok :occurrences (...)} | {:status :error :reason ...}
|
||||
;; The same merge works for any transport; an unreachable peer (:error) is
|
||||
;; skipped (graceful degradation), never breaking the agenda.
|
||||
|
||||
(define
|
||||
ev-find-peer
|
||||
(fn
|
||||
(peers pid)
|
||||
(cond
|
||||
((empty? peers) nil)
|
||||
((= (ev/peer-id (first peers)) pid) (first peers))
|
||||
(else (ev-find-peer (rest peers) pid)))))
|
||||
|
||||
;; In-process transport adapter: resolves a peer-id against a peer list and
|
||||
;; expands its :store. Lets the in-process model run through the same `fetch`
|
||||
;; interface a remote transport implements.
|
||||
(define
|
||||
ev/peer-fetch
|
||||
(fn
|
||||
(peers)
|
||||
(fn
|
||||
(pid ws we)
|
||||
(let
|
||||
((p (ev-find-peer peers pid)))
|
||||
(if
|
||||
(nil? p)
|
||||
{:status :error :reason :unknown-peer}
|
||||
{:status :ok :occurrences (ev/agenda (ev/peer-store p) ws we)})))))
|
||||
|
||||
;; Local agenda (:local) merged with each trusted peer's agenda fetched via the
|
||||
;; injected `fetch` transport, sorted by start, tagged with :origin. Peers that
|
||||
;; fail to fetch contribute nothing.
|
||||
(define
|
||||
ev/federated-agenda-via
|
||||
(fn
|
||||
(local-store trusted-ids ws we fetch)
|
||||
(let
|
||||
((acc (list)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn (o) (append! acc o))
|
||||
(ev-tag-origin (ev/agenda local-store ws we) :local))
|
||||
(for-each
|
||||
(fn
|
||||
(pid)
|
||||
(let
|
||||
((res (fetch pid ws we)))
|
||||
(when
|
||||
(= (get res :status) :ok)
|
||||
(for-each
|
||||
(fn (o) (append! acc o))
|
||||
(ev-tag-origin (get res :occurrences) pid)))))
|
||||
trusted-ids)
|
||||
(ev-fed-sort acc)))))
|
||||
|
||||
;; Reachability report: ((peer-id :ok|:error) ...) for the trusted peers.
|
||||
(define
|
||||
ev/federation-status
|
||||
(fn
|
||||
(trusted-ids ws we fetch)
|
||||
(map
|
||||
(fn (pid) (list pid (get (fetch pid ws we) :status)))
|
||||
trusted-ids)))
|
||||
@@ -1,482 +0,0 @@
|
||||
;; lib/events/ical.sx — iCalendar (RFC 5545) export.
|
||||
;;
|
||||
;; Serializes events to VEVENT / VCALENDAR text so a rose-ash calendar can be
|
||||
;; imported by any standard client (Google/Apple/Outlook). Datetimes are UTC
|
||||
;; epoch-minutes, emitted as basic-format UTC stamps (YYYYMMDDTHHMM00Z). The
|
||||
;; full RRULE / EXDATE / RDATE model maps directly to the standard properties.
|
||||
;;
|
||||
;; Export is line-oriented: `ev/event->ical-lines` returns the VEVENT as a list
|
||||
;; of content lines (no folding/CRLF — easy to assert on); `ev/ical-render`
|
||||
;; joins lines with CRLF, the on-the-wire format. Requires calendar.sx.
|
||||
|
||||
;; ---- formatting helpers ----
|
||||
|
||||
(define ev-ical-pad2 (fn (n) (if (< n 10) (str "0" n) (str n))))
|
||||
|
||||
(define
|
||||
ev-ical-pad4
|
||||
(fn
|
||||
(n)
|
||||
(cond
|
||||
((< n 10) (str "000" n))
|
||||
((< n 100) (str "00" n))
|
||||
((< n 1000) (str "0" n))
|
||||
(else (str n)))))
|
||||
|
||||
(define
|
||||
ev-ical-nth
|
||||
(fn
|
||||
(xs i)
|
||||
(if
|
||||
(= i 0)
|
||||
(first xs)
|
||||
(ev-ical-nth (rest xs) (- i 1)))))
|
||||
|
||||
(define
|
||||
ev-ical-join
|
||||
(fn
|
||||
(parts sep)
|
||||
(if
|
||||
(empty? parts)
|
||||
""
|
||||
(reduce (fn (acc p) (str acc sep p)) (first parts) (rest parts)))))
|
||||
|
||||
;; An epoch-minute as an iCal basic-format stamp (no zone suffix).
|
||||
(define
|
||||
ev-ical-dt-stamp
|
||||
(fn
|
||||
(t)
|
||||
(let
|
||||
((civ (ev-dt->civil t)) (tod (ev-dt-tod t)))
|
||||
(str
|
||||
(ev-ical-pad4 (ev-civ-y civ))
|
||||
(ev-ical-pad2 (ev-civ-m civ))
|
||||
(ev-ical-pad2 (ev-civ-d civ))
|
||||
"T"
|
||||
(ev-ical-pad2 (quotient tod 60))
|
||||
(ev-ical-pad2 (modulo tod 60))
|
||||
"00"))))
|
||||
|
||||
;; A UTC epoch-minute as a UTC stamp (trailing Z).
|
||||
(define ev-ical-dt (fn (t) (str (ev-ical-dt-stamp t) "Z")))
|
||||
|
||||
;; A local epoch-minute as a floating/local stamp (no Z) — used with TZID.
|
||||
(define ev-ical-dt-local ev-ical-dt-stamp)
|
||||
|
||||
;; A UTC offset in minutes as "+HHMM" / "-HHMM".
|
||||
(define
|
||||
ev-ical-offset
|
||||
(fn
|
||||
(mins)
|
||||
(let
|
||||
((a (abs mins)))
|
||||
(str
|
||||
(if (< mins 0) "-" "+")
|
||||
(ev-ical-pad2 (quotient a 60))
|
||||
(ev-ical-pad2 (modulo a 60))))))
|
||||
|
||||
;; A duration in minutes as an iCal DURATION value (PT#H#M).
|
||||
(define
|
||||
ev-ical-duration
|
||||
(fn
|
||||
(mins)
|
||||
(let
|
||||
((h (quotient mins 60)) (m (modulo mins 60)))
|
||||
(cond
|
||||
((and (> h 0) (> m 0)) (str "PT" h "H" m "M"))
|
||||
((> h 0) (str "PT" h "H"))
|
||||
(else (str "PT" m "M"))))))
|
||||
|
||||
(define
|
||||
ev-ical-wd
|
||||
(fn (w) (ev-ical-nth (list "MO" "TU" "WE" "TH" "FR" "SA" "SU") w)))
|
||||
|
||||
(define
|
||||
ev-ical-freq
|
||||
(fn
|
||||
(f)
|
||||
(cond
|
||||
((= f :daily) "DAILY")
|
||||
((= f :weekly) "WEEKLY")
|
||||
((= f :monthly) "MONTHLY")
|
||||
(else "DAILY"))))
|
||||
|
||||
;; One BYDAY token: a weekly weekday number -> "MO"; a monthly ordinal weekday
|
||||
;; {:ord :wd} -> "2TU" / "-1FR".
|
||||
(define
|
||||
ev-ical-byday-token
|
||||
(fn
|
||||
(e)
|
||||
(if
|
||||
(dict? e)
|
||||
(str (get e :ord) (ev-ical-wd (get e :wd)))
|
||||
(ev-ical-wd e))))
|
||||
|
||||
;; UNTIL converter: per RFC 5545, even a TZID DTSTART requires UNTIL in UTC, so
|
||||
;; a tz event converts its (local) UNTIL to UTC; a non-tz event passes through.
|
||||
(define
|
||||
ev-ical-conv
|
||||
(fn
|
||||
(event)
|
||||
(let
|
||||
((tz (get event :tz)))
|
||||
(if (nil? tz) (fn (t) t) (fn (t) (ev-tz-local->utc tz t))))))
|
||||
|
||||
;; ---- VTIMEZONE ----
|
||||
;; A tz event exports DTSTART;TZID=<name>:<local time> and the VCALENDAR carries
|
||||
;; a VTIMEZONE block defining the zone's DST rules, so a client recurs at a
|
||||
;; fixed WALL-CLOCK time (DST-correct) rather than fixed UTC.
|
||||
|
||||
;; A DST transition rule -> "FREQ=YEARLY;BYMONTH=<m>;BYDAY=<ord><WD>".
|
||||
(define
|
||||
ev-ical-vtz-rrule
|
||||
(fn
|
||||
(rule)
|
||||
(str
|
||||
"FREQ=YEARLY;BYMONTH="
|
||||
(get rule :month)
|
||||
";BYDAY="
|
||||
(get rule :ord)
|
||||
(ev-ical-wd (get rule :wd)))))
|
||||
|
||||
;; The transition's DTSTART (local time of the FROM offset) in a reference year.
|
||||
(define
|
||||
ev-ical-vtz-dtstart
|
||||
(fn
|
||||
(rule from-offset)
|
||||
(let
|
||||
((day (ev-resolve-nth-weekday 1970 (get rule :month) (get rule :ord) (get rule :wd))))
|
||||
(ev-ical-dt-local
|
||||
(+ (* (ev-days-from-civil 1970 (get rule :month) day) 1440)
|
||||
(get rule :time)
|
||||
from-offset)))))
|
||||
|
||||
;; The VTIMEZONE content lines for a zone (DAYLIGHT + STANDARD for :dst; a
|
||||
;; single STANDARD for :fixed).
|
||||
(define
|
||||
ev-ical-vtimezone
|
||||
(fn
|
||||
(tz)
|
||||
(if
|
||||
(= (get tz :kind) :dst)
|
||||
(let
|
||||
((std (get tz :std-offset))
|
||||
(dst (get tz :dst-offset))
|
||||
(sr (get tz :dst-start))
|
||||
(er (get tz :dst-end)))
|
||||
(list
|
||||
"BEGIN:VTIMEZONE"
|
||||
(str "TZID:" (get tz :name))
|
||||
"BEGIN:DAYLIGHT"
|
||||
(str "DTSTART:" (ev-ical-vtz-dtstart sr std))
|
||||
(str "TZOFFSETFROM:" (ev-ical-offset std))
|
||||
(str "TZOFFSETTO:" (ev-ical-offset dst))
|
||||
(str "RRULE:" (ev-ical-vtz-rrule sr))
|
||||
"END:DAYLIGHT"
|
||||
"BEGIN:STANDARD"
|
||||
(str "DTSTART:" (ev-ical-vtz-dtstart er dst))
|
||||
(str "TZOFFSETFROM:" (ev-ical-offset dst))
|
||||
(str "TZOFFSETTO:" (ev-ical-offset std))
|
||||
(str "RRULE:" (ev-ical-vtz-rrule er))
|
||||
"END:STANDARD"
|
||||
"END:VTIMEZONE"))
|
||||
(list
|
||||
"BEGIN:VTIMEZONE"
|
||||
(str "TZID:" (get tz :name))
|
||||
"BEGIN:STANDARD"
|
||||
"DTSTART:19700101T000000"
|
||||
(str "TZOFFSETFROM:" (ev-ical-offset (get tz :offset)))
|
||||
(str "TZOFFSETTO:" (ev-ical-offset (get tz :offset)))
|
||||
"END:STANDARD"
|
||||
"END:VTIMEZONE"))))
|
||||
|
||||
;; ---- RRULE ----
|
||||
(define
|
||||
ev-ical-rrule
|
||||
(fn
|
||||
(rrule conv)
|
||||
(let
|
||||
((parts (list (str "FREQ=" (ev-ical-freq (get rrule :freq))))))
|
||||
(begin
|
||||
(when
|
||||
(and
|
||||
(not (nil? (get rrule :interval)))
|
||||
(> (get rrule :interval) 1))
|
||||
(append! parts (str "INTERVAL=" (get rrule :interval))))
|
||||
(when
|
||||
(not (nil? (get rrule :count)))
|
||||
(append! parts (str "COUNT=" (get rrule :count))))
|
||||
(when
|
||||
(not (nil? (get rrule :until)))
|
||||
(append! parts (str "UNTIL=" (ev-ical-dt (conv (get rrule :until))))))
|
||||
(when
|
||||
(not (nil? (get rrule :byday)))
|
||||
(append!
|
||||
parts
|
||||
(str
|
||||
"BYDAY="
|
||||
(ev-ical-join (map ev-ical-byday-token (get rrule :byday)) ","))))
|
||||
(when
|
||||
(not (nil? (get rrule :bymonthday)))
|
||||
(append!
|
||||
parts
|
||||
(str
|
||||
"BYMONTHDAY="
|
||||
(ev-ical-join
|
||||
(map (fn (d) (str d)) (get rrule :bymonthday))
|
||||
","))))
|
||||
(str "RRULE:" (ev-ical-join parts ";"))))))
|
||||
|
||||
;; ---- VEVENT / VCALENDAR ----
|
||||
|
||||
;; The VEVENT content lines for an event (list of strings). A tz event uses
|
||||
;; DTSTART;TZID=<name>:<local> (matched by a VTIMEZONE at the VCALENDAR level)
|
||||
;; with EXDATE/RDATE in the same TZID-local form; UNTIL is always UTC. A non-tz
|
||||
;; event uses UTC `Z` stamps throughout.
|
||||
(define
|
||||
ev/event->ical-lines
|
||||
(fn
|
||||
(event)
|
||||
(let
|
||||
((lines (list "BEGIN:VEVENT"))
|
||||
(conv (ev-ical-conv event))
|
||||
(tz (get event :tz)))
|
||||
(let
|
||||
((dtparam (if (nil? tz) "" (str ";TZID=" (get tz :name))))
|
||||
(fmt (if (nil? tz) ev-ical-dt ev-ical-dt-local)))
|
||||
(begin
|
||||
(append! lines (str "UID:" (get event :id)))
|
||||
(append! lines (str "SUMMARY:" (get event :id)))
|
||||
(append! lines (str "DTSTART" dtparam ":" (fmt (get event :dtstart))))
|
||||
(append!
|
||||
lines
|
||||
(str "DURATION:" (ev-ical-duration (get event :duration))))
|
||||
(when
|
||||
(not (nil? (get event :rrule)))
|
||||
(append! lines (ev-ical-rrule (get event :rrule) conv)))
|
||||
(when
|
||||
(and
|
||||
(not (nil? (get event :exdate)))
|
||||
(> (len (get event :exdate)) 0))
|
||||
(append!
|
||||
lines
|
||||
(str
|
||||
"EXDATE"
|
||||
dtparam
|
||||
":"
|
||||
(ev-ical-join (map fmt (get event :exdate)) ","))))
|
||||
(when
|
||||
(and
|
||||
(not (nil? (get event :rdate)))
|
||||
(> (len (get event :rdate)) 0))
|
||||
(append!
|
||||
lines
|
||||
(str
|
||||
"RDATE"
|
||||
dtparam
|
||||
":"
|
||||
(ev-ical-join (map fmt (get event :rdate)) ","))))
|
||||
(append! lines "END:VEVENT")
|
||||
lines)))))
|
||||
|
||||
;; Collect the distinct timezones used by a list of events (by :name).
|
||||
(define
|
||||
ev-ical-distinct-tzs
|
||||
(fn
|
||||
(events)
|
||||
(reduce
|
||||
(fn
|
||||
(acc ev)
|
||||
(let
|
||||
((tz (get ev :tz)))
|
||||
(if
|
||||
(or (nil? tz) (ev-ical-tz-seen? acc (get tz :name)))
|
||||
acc
|
||||
(append acc (list tz)))))
|
||||
(list)
|
||||
events)))
|
||||
|
||||
(define
|
||||
ev-ical-tz-seen?
|
||||
(fn
|
||||
(tzs name)
|
||||
(cond
|
||||
((empty? tzs) false)
|
||||
((= (get (first tzs) :name) name) true)
|
||||
(else (ev-ical-tz-seen? (rest tzs) name)))))
|
||||
|
||||
;; A full VCALENDAR (list of content lines): a VTIMEZONE block for each distinct
|
||||
;; zone the events reference, then every VEVENT.
|
||||
(define
|
||||
ev/events->ical-lines
|
||||
(fn
|
||||
(events)
|
||||
(let
|
||||
((lines (list "BEGIN:VCALENDAR" "VERSION:2.0" "PRODID:-//rose-ash//events-on-sx//EN")))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(tz)
|
||||
(for-each (fn (l) (append! lines l)) (ev-ical-vtimezone tz)))
|
||||
(ev-ical-distinct-tzs events))
|
||||
(for-each
|
||||
(fn
|
||||
(ev)
|
||||
(for-each (fn (l) (append! lines l)) (ev/event->ical-lines ev)))
|
||||
events)
|
||||
(append! lines "END:VCALENDAR")
|
||||
lines))))
|
||||
|
||||
;; Render content lines to the on-the-wire iCalendar text (CRLF-separated).
|
||||
(define ev/ical-render (fn (lines) (ev-ical-join lines "\r\n")))
|
||||
|
||||
;; ---- import (parse VEVENT/VCALENDAR back into events) ----
|
||||
;; Inverse of the export above: parse iCalendar content lines into event dicts
|
||||
;; (ev-event-full shape). Capacity is not an iCal property, so imported events
|
||||
;; default to capacity 0 — set it after import if needed.
|
||||
|
||||
;; "20260601T180000Z" -> UTC epoch-minutes.
|
||||
(define
|
||||
ev-ical-parse-dt
|
||||
(fn
|
||||
(s)
|
||||
(ev-dt
|
||||
(string->number (substring s 0 4))
|
||||
(string->number (substring s 4 6))
|
||||
(string->number (substring s 6 8))
|
||||
(string->number (substring s 9 11))
|
||||
(string->number (substring s 11 13)))))
|
||||
|
||||
;; "30M" / "" -> minutes.
|
||||
(define
|
||||
ev-ical-parse-min
|
||||
(fn
|
||||
(s)
|
||||
(if (= (string-length s) 0) 0 (string->number (first (split s "M"))))))
|
||||
|
||||
;; "PT1H30M" / "PT1H" / "PT30M" -> minutes.
|
||||
(define
|
||||
ev-ical-parse-duration
|
||||
(fn
|
||||
(s)
|
||||
(let
|
||||
((body (substring s 2 (string-length s))))
|
||||
(let
|
||||
((hparts (split body "H")))
|
||||
(if
|
||||
(> (len hparts) 1)
|
||||
(+ (* 60 (string->number (first hparts))) (ev-ical-parse-min (first (rest hparts))))
|
||||
(ev-ical-parse-min body))))))
|
||||
|
||||
(define
|
||||
ev-ical-wd->num
|
||||
(fn
|
||||
(tok)
|
||||
(cond
|
||||
((= tok "MO") 0)
|
||||
((= tok "TU") 1)
|
||||
((= tok "WE") 2)
|
||||
((= tok "TH") 3)
|
||||
((= tok "FR") 4)
|
||||
((= tok "SA") 5)
|
||||
((= tok "SU") 6)
|
||||
(else 0))))
|
||||
|
||||
;; "MO" -> 0 ; "2TU" -> {:ord 2 :wd 1} ; "-1FR" -> {:ord -1 :wd 4}
|
||||
(define
|
||||
ev-ical-parse-byday-token
|
||||
(fn
|
||||
(tok)
|
||||
(let
|
||||
((n (string-length tok)))
|
||||
(if
|
||||
(= n 2)
|
||||
(ev-ical-wd->num tok)
|
||||
{:ord (string->number (substring tok 0 (- n 2)))
|
||||
:wd (ev-ical-wd->num (substring tok (- n 2) n))}))))
|
||||
|
||||
(define
|
||||
ev-ical-parse-freq
|
||||
(fn
|
||||
(v)
|
||||
(cond
|
||||
((= v "DAILY") :daily)
|
||||
((= v "WEEKLY") :weekly)
|
||||
((= v "MONTHLY") :monthly)
|
||||
(else :daily))))
|
||||
|
||||
;; "FREQ=WEEKLY;INTERVAL=2;UNTIL=...;BYDAY=MO,WE" -> rrule dict.
|
||||
(define
|
||||
ev-ical-parse-rrule
|
||||
(fn
|
||||
(val)
|
||||
(let
|
||||
((rr {}))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(p)
|
||||
(let
|
||||
((kv (split p "=")))
|
||||
(let
|
||||
((k (first kv)) (v (first (rest kv))))
|
||||
(cond
|
||||
((= k "FREQ") (dict-set! rr :freq (ev-ical-parse-freq v)))
|
||||
((= k "INTERVAL") (dict-set! rr :interval (string->number v)))
|
||||
((= k "COUNT") (dict-set! rr :count (string->number v)))
|
||||
((= k "UNTIL") (dict-set! rr :until (ev-ical-parse-dt v)))
|
||||
((= k "BYDAY") (dict-set! rr :byday (map ev-ical-parse-byday-token (split v ","))))
|
||||
((= k "BYMONTHDAY") (dict-set! rr :bymonthday (map string->number (split v ","))))
|
||||
(else nil)))))
|
||||
(split val ";"))
|
||||
rr))))
|
||||
|
||||
;; Parse a VEVENT's content lines into an event dict.
|
||||
(define
|
||||
ev/ical-lines->event
|
||||
(fn
|
||||
(lines)
|
||||
(let
|
||||
((ev {:capacity 0 :rrule nil}) (exd (list)) (rd (list)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(line)
|
||||
(let
|
||||
((kv (split line ":")))
|
||||
(when
|
||||
(> (len kv) 1)
|
||||
(let
|
||||
;; strip any property parameters (e.g. ";TZID=...") from the key
|
||||
((k (first (split (first kv) ";"))) (v (first (rest kv))))
|
||||
(cond
|
||||
((= k "UID") (dict-set! ev :id (string->symbol v)))
|
||||
((= k "DTSTART") (dict-set! ev :dtstart (ev-ical-parse-dt v)))
|
||||
((= k "DURATION") (dict-set! ev :duration (ev-ical-parse-duration v)))
|
||||
((= k "RRULE") (dict-set! ev :rrule (ev-ical-parse-rrule v)))
|
||||
((= k "EXDATE") (set! exd (map ev-ical-parse-dt (split v ","))))
|
||||
((= k "RDATE") (set! rd (map ev-ical-parse-dt (split v ","))))
|
||||
(else nil))))))
|
||||
lines)
|
||||
(dict-set! ev :exdate exd)
|
||||
(dict-set! ev :rdate rd)
|
||||
ev))))
|
||||
|
||||
;; Split a VCALENDAR line list into per-VEVENT line groups.
|
||||
(define
|
||||
ev-ical-group-vevents
|
||||
(fn
|
||||
(lines cur in acc)
|
||||
(cond
|
||||
((empty? lines) acc)
|
||||
((= (first lines) "BEGIN:VEVENT") (ev-ical-group-vevents (rest lines) (list) true acc))
|
||||
((= (first lines) "END:VEVENT") (ev-ical-group-vevents (rest lines) (list) false (append acc (list cur))))
|
||||
(in (ev-ical-group-vevents (rest lines) (append cur (list (first lines))) true acc))
|
||||
(else (ev-ical-group-vevents (rest lines) cur false acc)))))
|
||||
|
||||
;; Parse a VCALENDAR line list into a list of events.
|
||||
(define
|
||||
ev/parse-vcalendar
|
||||
(fn
|
||||
(lines)
|
||||
(map ev/ical-lines->event (ev-ical-group-vevents lines (list) false (list)))))
|
||||
@@ -1,97 +0,0 @@
|
||||
;; 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))))
|
||||
|
||||
;; ---- end-to-end delivery: SX messages -> the notify flow ----
|
||||
;; Bridges the SX notification-derivation modules (reminders / booking-notify /
|
||||
;; reschedule) to the durable delivery flow. An SX message (id recipient body)
|
||||
;; is serialized to s-expression text and spliced into the Scheme program as
|
||||
;; quoted data, then the digest flow delivers the batch over an injected
|
||||
;; transport. Strings round-trip through the guest Scheme as {:scm-string ...}
|
||||
;; boxes; results are unboxed back to plain SX.
|
||||
|
||||
;; A default transport (Scheme source): always reports delivered.
|
||||
(define ev-notify-ok-transport "(lambda (k p) (list (quote ok) (quote sent)))")
|
||||
|
||||
(define
|
||||
ev-notify-join
|
||||
(fn
|
||||
(parts sep)
|
||||
(if
|
||||
(empty? parts)
|
||||
""
|
||||
(reduce (fn (acc p) (str acc sep p)) (first parts) (rest parts)))))
|
||||
|
||||
(define ev-msg->quoted (fn (m) (str "(quote " (serialize m) ")")))
|
||||
|
||||
(define
|
||||
ev-msgs->scheme
|
||||
(fn
|
||||
(msgs)
|
||||
(str "(list " (ev-notify-join (map ev-msg->quoted msgs) " ") ")")))
|
||||
|
||||
(define
|
||||
ev-unbox-str
|
||||
(fn
|
||||
(x)
|
||||
(if (and (dict? x) (has-key? x :scm-string)) (get x :scm-string) x)))
|
||||
|
||||
(define
|
||||
ev-unbox-result
|
||||
(fn (r) (map (fn (item) (map ev-unbox-str item)) r)))
|
||||
|
||||
;; Deliver a list of SX messages through the digest flow over `transport-src`
|
||||
;; (a Scheme (kind payload) -> (ok ..)|(retry reason) lambda source). `maxn`
|
||||
;; bounds retries per message, `maxticks` bounds host service ticks. Returns the
|
||||
;; per-message outcomes unboxed: (("delivered"|"failed" <id> <n-or-reason>) ...)
|
||||
(define
|
||||
ev/deliver-messages
|
||||
(fn
|
||||
(msgs transport-src maxn maxticks)
|
||||
(ev-unbox-result
|
||||
(ev/notify-run
|
||||
(str
|
||||
"(define msgs "
|
||||
(ev-msgs->scheme msgs)
|
||||
") (if (null? msgs) (list) (let ((s (flow/start (ev-deliver-digest "
|
||||
maxn
|
||||
") msgs))) (begin (flow-run-host "
|
||||
transport-src
|
||||
" "
|
||||
maxticks
|
||||
") (flow/result (car (cdr s))))))")))))
|
||||
@@ -1,147 +0,0 @@
|
||||
;; 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}))
|
||||
|
||||
;; ---- reschedule notifications ----
|
||||
;; When an event carries per-occurrence overrides (ev-with-override), every
|
||||
;; attendee booked at the ORIGINAL start should be told the new time. Bookings
|
||||
;; were made against the original occ-key (id@orig-start), so we read that
|
||||
;; roster. Idempotency key encodes the original key and the new start, so
|
||||
;; re-deriving the same reschedule never double-notifies.
|
||||
(define
|
||||
ev/reschedule-notifications
|
||||
(fn
|
||||
(b event)
|
||||
(let
|
||||
((overrides (ev-or (get event :overrides) (list)))
|
||||
(evid (get event :id))
|
||||
(dur (get event :duration)))
|
||||
(reduce
|
||||
(fn
|
||||
(acc entry)
|
||||
(let
|
||||
((orig-start (first entry))
|
||||
(ov (first (rest entry))))
|
||||
(let
|
||||
((occ (ev-occ evid orig-start dur))
|
||||
(new-start (get ov :start))
|
||||
(new-duration (get ov :duration)))
|
||||
(let
|
||||
((key (ev-occ-key occ)))
|
||||
(append
|
||||
acc
|
||||
(map
|
||||
(fn
|
||||
(actor)
|
||||
{:id (str key "/reschedule/" new-start)
|
||||
:recipient actor
|
||||
:event evid
|
||||
:old-start orig-start
|
||||
:new-start new-start
|
||||
:new-duration new-duration})
|
||||
(ev/roster-occ b occ)))))))
|
||||
(list)
|
||||
overrides))))
|
||||
|
||||
;; Project a reschedule notification to notify's (id recipient body) shape.
|
||||
(define
|
||||
ev/reschedule-notify->msg
|
||||
(fn
|
||||
(r)
|
||||
(list
|
||||
(get r :id)
|
||||
(get r :recipient)
|
||||
(list :rescheduled (get r :event) (get r :old-start) (get r :new-start)))))
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"lang": "events",
|
||||
"total_passed": 376,
|
||||
"total_failed": 0,
|
||||
"total": 376,
|
||||
"suites": [
|
||||
{"name":"calendar","passed":51,"failed":0,"total":51},
|
||||
{"name":"timezone","passed":17,"failed":0,"total":17},
|
||||
{"name":"ical","passed":56,"failed":0,"total":56},
|
||||
{"name":"availability","passed":22,"failed":0,"total":22},
|
||||
{"name":"api","passed":41,"failed":0,"total":41},
|
||||
{"name":"booking","passed":82,"failed":0,"total":82},
|
||||
{"name":"booking-notify","passed":11,"failed":0,"total":11},
|
||||
{"name":"ticket","passed":31,"failed":0,"total":31},
|
||||
{"name":"notify","passed":7,"failed":0,"total":7},
|
||||
{"name":"reminders","passed":21,"failed":0,"total":21},
|
||||
{"name":"federation","passed":29,"failed":0,"total":29},
|
||||
{"name":"integration","passed":8,"failed":0,"total":8}
|
||||
],
|
||||
"generated": "2026-06-07T20:02:48+00:00"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
# events scoreboard
|
||||
|
||||
**376 / 376 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
| calendar | 51 | 51 | ok |
|
||||
| timezone | 17 | 17 | ok |
|
||||
| ical | 56 | 56 | ok |
|
||||
| availability | 22 | 22 | ok |
|
||||
| api | 41 | 41 | ok |
|
||||
| booking | 82 | 82 | ok |
|
||||
| booking-notify | 11 | 11 | ok |
|
||||
| ticket | 31 | 31 | ok |
|
||||
| notify | 7 | 7 | ok |
|
||||
| reminders | 21 | 21 | ok |
|
||||
| federation | 29 | 29 | ok |
|
||||
| integration | 8 | 8 | ok |
|
||||
@@ -1,392 +0,0 @@
|
||||
;; 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))))))))))))
|
||||
|
||||
;; ---- conflict-checked booking ----
|
||||
(define
|
||||
ev-api-cf-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((b (persist/open))
|
||||
(store
|
||||
(ev/schedule
|
||||
(ev/schedule
|
||||
(ev/schedule (ev/empty) (quote a) (ev-dt 2026 6 1 9 0) 60 nil 10)
|
||||
(quote bb)
|
||||
(ev-dt 2026 6 1 9 30)
|
||||
60
|
||||
nil
|
||||
10)
|
||||
(quote c)
|
||||
(ev-dt 2026 6 1 11 0)
|
||||
60
|
||||
nil
|
||||
10)))
|
||||
(let
|
||||
((oa (ev-occ (quote a) (ev-dt 2026 6 1 9 0) 60))
|
||||
(ob (ev-occ (quote bb) (ev-dt 2026 6 1 9 30) 60))
|
||||
(oc (ev-occ (quote c) (ev-dt 2026 6 1 11 0) 60)))
|
||||
(do
|
||||
(ev-api-check!
|
||||
"first checked booking succeeds"
|
||||
(get (ev/book-checked! b store (quote nia) oa) :status)
|
||||
:booked)
|
||||
(ev-api-check!
|
||||
"overlapping different-event booking is a time conflict"
|
||||
(get (ev/book-checked! b store (quote nia) ob) :status)
|
||||
:time-conflict)
|
||||
(ev-api-check!
|
||||
"the clashing booking did not land on the roster"
|
||||
(ev/roster-occ b ob)
|
||||
(list))
|
||||
(ev-api-check!
|
||||
"a non-overlapping booking is allowed"
|
||||
(get (ev/book-checked! b store (quote nia) oc) :status)
|
||||
:booked)
|
||||
(ev-api-check!
|
||||
"re-booking the same occurrence is idempotent, not a conflict"
|
||||
(get (ev/book-checked! b store (quote nia) oa) :status)
|
||||
:already)
|
||||
;; a different actor is unaffected by nia's bookings
|
||||
(ev-api-check!
|
||||
"another actor may take the overlapping slot"
|
||||
(get (ev/book-checked! b store (quote ola) ob) :status)
|
||||
:booked)
|
||||
(ev-api-check!
|
||||
"would-time-conflict? predicate agrees"
|
||||
(ev/would-time-conflict? b store (quote nia) ob)
|
||||
true)
|
||||
(ev-api-check!
|
||||
"would-time-conflict? false for a free slot"
|
||||
(ev/would-time-conflict? b store (quote zed) ob)
|
||||
false))))))
|
||||
|
||||
;; ---- whole-series booking ----
|
||||
(define
|
||||
ev-api-sr-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((b (persist/open))
|
||||
(store
|
||||
(ev/schedule
|
||||
(ev/empty)
|
||||
(quote yoga)
|
||||
(ev-dt 2026 6 1 18 0)
|
||||
60
|
||||
{:freq :weekly :byday (list 0 2) :count 4}
|
||||
20))
|
||||
(ws (ev-date 2026 6 1))
|
||||
(we (ev-date 2026 7 1)))
|
||||
(do
|
||||
(let
|
||||
((res (ev/book-series! b store (quote nia) (quote yoga) ws we)))
|
||||
(do
|
||||
(ev-api-check! "series booking covers all four occurrences" (len res) 4)
|
||||
(ev-api-check! "all occurrences booked" (ev/series-count res :booked) 4)
|
||||
(ev-api-check!
|
||||
"actor is now booked into the whole series"
|
||||
(len (ev/series-booked b store (quote nia) (quote yoga) ws we))
|
||||
4)))
|
||||
;; re-booking the series is idempotent
|
||||
(ev-api-check!
|
||||
"re-booking the series is idempotent"
|
||||
(ev/series-count (ev/book-series! b store (quote nia) (quote yoga) ws we) :already)
|
||||
4)
|
||||
;; cancel the whole series
|
||||
(let
|
||||
((res (ev/cancel-series! b store (quote nia) (quote yoga) ws we)))
|
||||
(do
|
||||
(ev-api-check! "series cancel reports four cancellations" (ev/series-count res :cancelled) 4)
|
||||
(ev-api-check!
|
||||
"actor booked into nothing after series cancel"
|
||||
(len (ev/series-booked b store (quote nia) (quote yoga) ws we))
|
||||
0)))
|
||||
;; capacity interacts per-occurrence: fill one occurrence first
|
||||
(let
|
||||
((b2 (persist/open))
|
||||
(s2
|
||||
(ev/schedule (ev/empty) (quote clinic) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)))
|
||||
(do
|
||||
(ev/book-occ! b2 s2 (quote x) (ev-occ (quote clinic) (ev-dt 2026 6 2 9 0) 30))
|
||||
(let
|
||||
((res (ev/book-series! b2 s2 (quote nia) (quote clinic) (ev-date 2026 6 1) (ev-date 2026 6 10))))
|
||||
(do
|
||||
(ev-api-check! "series booking succeeds on free occurrences" (ev/series-count res :booked) 2)
|
||||
(ev-api-check! "series booking hits :full where capacity is taken" (ev/series-count res :full) 1)))))
|
||||
;; unknown event id
|
||||
(ev-api-check!
|
||||
"series booking an unknown event yields no results"
|
||||
(ev/book-series! b store (quote nia) (quote nope) ws we)
|
||||
(list))))))
|
||||
|
||||
(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!)
|
||||
(ev-api-cf-run-all!)
|
||||
(ev-api-sr-run-all!)
|
||||
{:failures ev-api-failures :total (+ ev-api-pass ev-api-fail) :passed ev-api-pass :failed ev-api-fail})))
|
||||
@@ -1,331 +0,0 @@
|
||||
;; 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})))
|
||||
@@ -1,137 +0,0 @@
|
||||
;; lib/events/tests/booking-notify.sx — lifecycle notifications from the stream.
|
||||
|
||||
(define ev-bn-pass 0)
|
||||
(define ev-bn-fail 0)
|
||||
(define ev-bn-failures (list))
|
||||
|
||||
(define
|
||||
ev-bn-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-bn-pass (+ ev-bn-pass 1))
|
||||
(do
|
||||
(set! ev-bn-fail (+ ev-bn-fail 1))
|
||||
(append!
|
||||
ev-bn-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define
|
||||
ev-bn-kinds
|
||||
(fn
|
||||
(notifs)
|
||||
(map (fn (n) (list (get n :recipient) (get n :kind))) notifs)))
|
||||
|
||||
(define
|
||||
ev-bn-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "o" 1 (quote a))
|
||||
(ev/waitlist! b "o" 1 (quote x))
|
||||
(ev/cancel-promote! b "o" 1 (quote a))
|
||||
(let
|
||||
((ns (ev/booking-notifications b "o" (quote yoga))))
|
||||
(do
|
||||
(ev-bn-check!
|
||||
"lifecycle notifications in order"
|
||||
(ev-bn-kinds ns)
|
||||
(list
|
||||
(list (quote a) :booked)
|
||||
(list (quote x) :waitlisted)
|
||||
(list (quote a) :cancelled)
|
||||
(list (quote x) :promoted)))
|
||||
(ev-bn-check!
|
||||
"promotion targets the waitlisted actor"
|
||||
(map
|
||||
(fn (n) (get n :recipient))
|
||||
(ev/notify-of-kind ns :promoted))
|
||||
(list (quote x)))
|
||||
(ev-bn-check!
|
||||
"a fresh booking is not flagged as a promotion"
|
||||
(len (ev/notify-of-kind ns :booked))
|
||||
1)
|
||||
(ev-bn-check!
|
||||
"every notification carries the event label"
|
||||
(get (first ns) :event)
|
||||
(quote yoga))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/hold! b "p" 3 (quote q))
|
||||
(ev/confirm! b "p" (quote q))
|
||||
(ev-bn-check!
|
||||
"hold then confirm notifications"
|
||||
(ev-bn-kinds (ev/booking-notifications b "p" (quote gig)))
|
||||
(list (list (quote q) :held) (list (quote q) :confirmed)))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/hold! b "r" 1 (quote q))
|
||||
(ev/release! b "r" (quote q))
|
||||
(ev-bn-check!
|
||||
"hold then release notifications"
|
||||
(ev-bn-kinds (ev/booking-notifications b "r" (quote gig)))
|
||||
(list (list (quote q) :held) (list (quote q) :released)))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "k" 5 (quote a))
|
||||
(ev/book! b "k" 5 (quote c))
|
||||
(let
|
||||
((ns (ev/booking-notifications b "k" (quote talk))))
|
||||
(do
|
||||
(ev-bn-check!
|
||||
"notification ids are occ-key/seq"
|
||||
(map (fn (n) (get n :id)) ns)
|
||||
(list "k/1" "k/2"))
|
||||
(ev-bn-check!
|
||||
"re-deriving yields identical ids (idempotent)"
|
||||
(map
|
||||
(fn (n) (get n :id))
|
||||
(ev/booking-notifications b "k" (quote talk)))
|
||||
(list "k/1" "k/2"))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "w" 5 (quote a))
|
||||
(ev-bn-check!
|
||||
"notification projects to (id recipient body)"
|
||||
(ev/booking-notify->msg
|
||||
(first (ev/booking-notifications b "w" (quote talk))))
|
||||
(list
|
||||
"w/1"
|
||||
(quote a)
|
||||
(list :booking-event :booked (quote talk))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "u" 1 (quote a))
|
||||
(ev/waitlist! b "u" 1 (quote x))
|
||||
(ev/leave-waitlist! b "u" (quote x))
|
||||
(ev-bn-check!
|
||||
"leaving the waitlist emits no notification"
|
||||
(len
|
||||
(ev/notify-of-kind
|
||||
(ev/booking-notifications b "u" (quote e))
|
||||
:left-waitlist))
|
||||
0)
|
||||
(ev-bn-check!
|
||||
"unbooked occurrence has no notifications"
|
||||
(ev/booking-notifications b "empty" (quote e))
|
||||
(list)))))))
|
||||
|
||||
(define
|
||||
ev-booking-notify-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-bn-pass 0)
|
||||
(set! ev-bn-fail 0)
|
||||
(set! ev-bn-failures (list))
|
||||
(ev-bn-run-all!)
|
||||
{:failures ev-bn-failures :total (+ ev-bn-pass ev-bn-fail) :passed ev-bn-pass :failed ev-bn-fail})))
|
||||
@@ -1,431 +0,0 @@
|
||||
;; 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))))))
|
||||
|
||||
;; ---- waitlist ----
|
||||
(define
|
||||
ev-bk-wl-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
;; join the waitlist when full; book directly when a seat is free
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev-bk-check! "waitlist! books when a seat is free" (get (ev/waitlist! b "w" 2 (quote a)) :status) :booked)
|
||||
(ev-bk-check! "second booking still fits" (get (ev/waitlist! b "w" 2 (quote c)) :status) :booked)
|
||||
(ev-bk-check! "third joins the waitlist when full" (get (ev/waitlist! b "w" 2 (quote x)) :status) :waitlisted)
|
||||
(ev-bk-check! "fourth is next in line" (get (ev/waitlist! b "w" 2 (quote y)) :position) 2)
|
||||
(ev-bk-check! "waitlist is FIFO" (ev/waitlist b "w") (list (quote x) (quote y)))
|
||||
(ev-bk-check! "seats unaffected by waitlisting" (ev/roster b "w") (list (quote a) (quote c)))
|
||||
(ev-bk-check! "waitlist-position reports a queued actor" (ev/waitlist-position b "w" (quote y)) 2)
|
||||
(ev-bk-check! "waitlist-position 0 for a seated actor" (ev/waitlist-position b "w" (quote a)) 0)))
|
||||
;; idempotency
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/waitlist! b "wi" 1 (quote a))
|
||||
(ev/waitlist! b "wi" 1 (quote x))
|
||||
(ev-bk-check! "re-joining as a seated actor is :already" (get (ev/waitlist! b "wi" 1 (quote a)) :status) :already)
|
||||
(ev-bk-check! "re-joining the queue is :already-waiting" (get (ev/waitlist! b "wi" 1 (quote x)) :status) :already-waiting)
|
||||
(ev-bk-check! "queue did not grow on re-join" (ev/waitlist b "wi") (list (quote x)))))
|
||||
;; leaving the waitlist
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/waitlist! b "wl" 1 (quote a))
|
||||
(ev/waitlist! b "wl" 1 (quote x))
|
||||
(ev/waitlist! b "wl" 1 (quote y))
|
||||
(ev-bk-check! "leave-waitlist reports left" (get (ev/leave-waitlist! b "wl" (quote x)) :status) :left)
|
||||
(ev-bk-check! "leaving removes from the queue" (ev/waitlist b "wl") (list (quote y)))
|
||||
(ev-bk-check! "leaving when not queued is not-waiting" (get (ev/leave-waitlist! b "wl" (quote z)) :status) :not-waiting)))
|
||||
;; auto-promotion on cancel
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/waitlist! b "wp" 1 (quote a))
|
||||
(ev/waitlist! b "wp" 1 (quote x))
|
||||
(ev/waitlist! b "wp" 1 (quote y))
|
||||
(let
|
||||
((r (ev/cancel-promote! b "wp" 1 (quote a))))
|
||||
(do
|
||||
(ev-bk-check! "cancel-promote cancels the seat holder" (get r :status) :cancelled)
|
||||
(ev-bk-check! "cancel-promote promotes the head of the queue" (get r :promoted) (quote x))))
|
||||
(ev-bk-check! "promoted actor now holds the seat" (ev/roster b "wp") (list (quote x)))
|
||||
(ev-bk-check! "promoted actor left the queue" (ev/waitlist b "wp") (list (quote y)))
|
||||
(ev-bk-check! "promoted seat is confirmed" (ev/seat-state b "wp" (quote x)) :confirmed)
|
||||
;; cancelling with an empty waitlist promotes nobody
|
||||
(ev/leave-waitlist! b "wp" (quote y))
|
||||
(let
|
||||
((r2 (ev/cancel-promote! b "wp" 1 (quote x))))
|
||||
(ev-bk-check! "cancel with empty waitlist promotes nobody" (get r2 :promoted) nil))
|
||||
(ev-bk-check! "seat is free after the last cancel" (ev/seats-left b "wp" 1) 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!)
|
||||
(ev-bk-wl-run-all!)
|
||||
{:failures ev-bk-failures :total (+ ev-bk-pass ev-bk-fail) :passed ev-bk-pass :failed ev-bk-fail})))
|
||||
@@ -1,592 +0,0 @@
|
||||
;; 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))))))))
|
||||
|
||||
;; ---- EXDATE / RDATE exceptions ----
|
||||
(define
|
||||
ev-cal-ex-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
;; EXDATE removes a matching occurrence from the recurrence
|
||||
(let
|
||||
((ex
|
||||
(ev-event-full
|
||||
(quote standup)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 5}
|
||||
1
|
||||
(list (ev-dt 2026 6 3 9 0))
|
||||
(list))))
|
||||
(ev-cal-check!
|
||||
"EXDATE excludes the matching occurrence"
|
||||
(ev-cal-starts (ev-expand ex (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
(list (list 2026 6 1) (list 2026 6 2) (list 2026 6 4) (list 2026 6 5))))
|
||||
;; EXDATE that matches nothing is a no-op
|
||||
(let
|
||||
((ex2
|
||||
(ev-event-full
|
||||
(quote s)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 3}
|
||||
1
|
||||
(list (ev-dt 2026 6 9 9 0))
|
||||
(list))))
|
||||
(ev-cal-check!
|
||||
"EXDATE not matching any occurrence is a no-op"
|
||||
(len (ev-expand ex2 (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
3))
|
||||
;; RDATE adds an explicit occurrence (within the window)
|
||||
(let
|
||||
((rd
|
||||
(ev-event-full
|
||||
(quote s)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 3}
|
||||
1
|
||||
(list)
|
||||
(list (ev-dt 2026 6 10 9 0)))))
|
||||
(do
|
||||
(ev-cal-check!
|
||||
"RDATE adds an explicit occurrence, sorted in"
|
||||
(ev-cal-starts (ev-expand rd (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 10)))
|
||||
(ev-cal-check!
|
||||
"RDATE outside the window is dropped"
|
||||
(len (ev-expand rd (ev-date 2026 6 1) (ev-date 2026 6 5)))
|
||||
3)))
|
||||
;; RDATE coinciding with an rrule occurrence is de-duplicated
|
||||
(let
|
||||
((rdup
|
||||
(ev-event-full
|
||||
(quote s)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 3}
|
||||
1
|
||||
(list)
|
||||
(list (ev-dt 2026 6 2 9 0)))))
|
||||
(ev-cal-check!
|
||||
"RDATE duplicating an occurrence does not double it"
|
||||
(len (ev-expand rdup (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
3))
|
||||
;; EXDATE wins over RDATE for the same datetime
|
||||
(let
|
||||
((both
|
||||
(ev-event-full
|
||||
(quote s)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 3}
|
||||
1
|
||||
(list (ev-dt 2026 6 2 9 0))
|
||||
(list (ev-dt 2026 6 2 9 0)))))
|
||||
(ev-cal-check!
|
||||
"EXDATE wins over RDATE and the rrule for the same date"
|
||||
(ev-cal-starts (ev-expand both (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
(list (list 2026 6 1) (list 2026 6 3))))
|
||||
;; RDATE-only event (no rrule)
|
||||
(let
|
||||
((ronly
|
||||
(ev-event-full
|
||||
(quote s)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
nil
|
||||
1
|
||||
(list)
|
||||
(list (ev-dt 2026 6 5 9 0) (ev-dt 2026 6 3 9 0)))))
|
||||
(ev-cal-check!
|
||||
"RDATE-only event yields dtstart plus the extra dates, sorted"
|
||||
(ev-cal-starts (ev-expand ronly (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
(list (list 2026 6 1) (list 2026 6 3) (list 2026 6 5))))
|
||||
;; plain ev-event (no exception keys) is unaffected
|
||||
(let
|
||||
((plain (ev-event (quote p) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)))
|
||||
(ev-cal-check!
|
||||
"plain event without exceptions expands unchanged"
|
||||
(len (ev-expand plain (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
3)))))
|
||||
|
||||
;; ---- per-occurrence overrides (reschedule one instance) ----
|
||||
(define
|
||||
ev-cal-ov-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((base (ev-event (quote standup) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 4} 1)))
|
||||
(do
|
||||
;; reschedule one instance to a new time + duration
|
||||
(let
|
||||
((moved (ev-with-override base (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0) 45)))
|
||||
(let
|
||||
((occs (ev-expand moved (ev-date 2026 6 1) (ev-date 2026 6 5))))
|
||||
(do
|
||||
(ev-cal-check!
|
||||
"override moves only the targeted instance"
|
||||
(map (fn (o) (ev-dt-tod (get o :start))) occs)
|
||||
(list 540 840 540 540))
|
||||
(ev-cal-check!
|
||||
"override applies the new duration"
|
||||
(map (fn (o) (- (get o :end) (get o :start))) occs)
|
||||
(list 30 45 30 30))
|
||||
(ev-cal-check!
|
||||
"override keeps the series length"
|
||||
(len occs)
|
||||
4))))
|
||||
;; an instance moved out of the window vacates its slot
|
||||
(let
|
||||
((movedout (ev-with-override base (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 20 9 0) 30)))
|
||||
(ev-cal-check!
|
||||
"instance moved out of window is dropped, slot vacated"
|
||||
(ev-cal-starts (ev-expand movedout (ev-date 2026 6 1) (ev-date 2026 6 5)))
|
||||
(list (list 2026 6 1) (list 2026 6 3) (list 2026 6 4))))
|
||||
;; override for a non-existent original start is a no-op
|
||||
(let
|
||||
((noop (ev-with-override base (ev-dt 2026 6 9 9 0) (ev-dt 2026 6 9 14 0) 45)))
|
||||
(ev-cal-check!
|
||||
"override for a non-occurring start is a no-op"
|
||||
(len (ev-expand noop (ev-date 2026 6 1) (ev-date 2026 6 5)))
|
||||
4))
|
||||
;; overrides re-sort the agenda when an instance moves earlier
|
||||
(let
|
||||
((early (ev-with-override base (ev-dt 2026 6 3 9 0) (ev-dt 2026 6 1 7 0) 30)))
|
||||
(ev-cal-check!
|
||||
"an instance moved earlier re-sorts into place"
|
||||
(map (fn (o) (ev-dt-tod (get o :start))) (ev-expand early (ev-date 2026 6 1) (ev-date 2026 6 5)))
|
||||
(list 420 540 540 540)))))))
|
||||
|
||||
(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!)
|
||||
(ev-cal-ex-run-all!)
|
||||
(ev-cal-ov-run-all!)
|
||||
{:failures ev-cal-failures :total (+ ev-cal-pass ev-cal-fail) :passed ev-cal-pass :failed ev-cal-fail})))
|
||||
@@ -1,289 +0,0 @@
|
||||
;; 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))))))
|
||||
|
||||
;; ---- injected transport (fed-sx) ----
|
||||
(define
|
||||
ev-fd-tx-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((local (ev/schedule (ev/empty) (quote yoga) (ev-dt 2026 6 1 9 0) 60 nil 20))
|
||||
(berlin (ev/peer (quote berlin) (ev/schedule (ev/empty) (quote meetup) (ev-dt 2026 6 1 12 0) 90 nil 100)))
|
||||
(ws (ev-date 2026 6 1))
|
||||
(we (ev-date 2026 6 2)))
|
||||
(let
|
||||
((fetch (ev/peer-fetch (list berlin))))
|
||||
(do
|
||||
;; in-process adapter merges through the transport interface
|
||||
(ev-fd-check!
|
||||
"federated-agenda-via merges local + fetched peer"
|
||||
(map (fn (o) (list (get o :origin) (get o :id)))
|
||||
(ev/federated-agenda-via local (list (quote berlin)) ws we fetch))
|
||||
(list (list :local (quote yoga)) (list (quote berlin) (quote meetup))))
|
||||
;; an unreachable / unknown peer degrades gracefully
|
||||
(ev-fd-check!
|
||||
"an unreachable peer is skipped, agenda still served"
|
||||
(map (fn (o) (get o :origin))
|
||||
(ev/federated-agenda-via local (list (quote berlin) (quote ghost)) ws we fetch))
|
||||
(list :local (quote berlin)))
|
||||
;; reachability report
|
||||
(ev-fd-check!
|
||||
"federation-status reports per-peer reachability"
|
||||
(ev/federation-status (list (quote berlin) (quote ghost)) ws we fetch)
|
||||
(list (list (quote berlin) :ok) (list (quote ghost) :error)))
|
||||
;; an explicit remote transport (returns occurrences directly)
|
||||
(let
|
||||
((remote-fetch
|
||||
(fn
|
||||
(pid rws rwe)
|
||||
(if (= pid (quote tokyo))
|
||||
{:status :ok
|
||||
:occurrences (list (ev-occ (quote standup) (ev-dt 2026 6 1 8 0) 15))}
|
||||
{:status :error :reason :unreachable}))))
|
||||
(do
|
||||
(ev-fd-check!
|
||||
"a remote transport's occurrences merge with origin tags"
|
||||
(map (fn (o) (list (get o :origin) (get o :id)))
|
||||
(ev/federated-agenda-via local (list (quote tokyo)) ws we remote-fetch))
|
||||
(list (list (quote tokyo) (quote standup)) (list :local (quote yoga))))
|
||||
(ev-fd-check!
|
||||
"remote transport error degrades to local only"
|
||||
(map (fn (o) (get o :origin))
|
||||
(ev/federated-agenda-via local (list (quote osaka)) ws we remote-fetch))
|
||||
(list :local))))
|
||||
;; no trusted peers -> only local
|
||||
(ev-fd-check!
|
||||
"no trusted peer ids yields only local"
|
||||
(map (fn (o) (get o :origin))
|
||||
(ev/federated-agenda-via local (list) ws we fetch))
|
||||
(list :local)))))))
|
||||
|
||||
(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!)
|
||||
(ev-fd-tx-run-all!)
|
||||
{:failures ev-fd-failures :total (+ ev-fd-pass ev-fd-fail) :passed ev-fd-pass :failed ev-fd-fail})))
|
||||
@@ -1,387 +0,0 @@
|
||||
;; lib/events/tests/ical.sx — iCalendar (RFC 5545) export.
|
||||
|
||||
(define ev-ic-pass 0)
|
||||
(define ev-ic-fail 0)
|
||||
(define ev-ic-failures (list))
|
||||
|
||||
(define
|
||||
ev-ic-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-ic-pass (+ ev-ic-pass 1))
|
||||
(do
|
||||
(set! ev-ic-fail (+ ev-ic-fail 1))
|
||||
(append!
|
||||
ev-ic-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Find the value of a "KEY:value" line in a VEVENT line list (or nil).
|
||||
(define
|
||||
ev-ic-line
|
||||
(fn
|
||||
(lines key)
|
||||
(cond
|
||||
((empty? lines) nil)
|
||||
((ev-ic-prefix? (first lines) (str key ":")) (first lines))
|
||||
(else (ev-ic-line (rest lines) key)))))
|
||||
|
||||
(define
|
||||
ev-ic-prefix?
|
||||
(fn
|
||||
(s p)
|
||||
(and (>= (len s) (len p)) (= (substring s 0 (len p)) p))))
|
||||
|
||||
(define
|
||||
ev-ic-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((lines (ev/event->ical-lines (ev-event (quote one) (ev-dt 2026 6 10 14 0) 60 nil 1))))
|
||||
(do
|
||||
(ev-ic-check! "VEVENT opens" (first lines) "BEGIN:VEVENT")
|
||||
(ev-ic-check! "VEVENT closes" (ev-ic-line lines "END") "END:VEVENT")
|
||||
(ev-ic-check!
|
||||
"UID is the event id"
|
||||
(ev-ic-line lines "UID")
|
||||
"UID:one")
|
||||
(ev-ic-check!
|
||||
"DTSTART is a UTC basic-format stamp"
|
||||
(ev-ic-line lines "DTSTART")
|
||||
"DTSTART:20260610T140000Z")
|
||||
(ev-ic-check!
|
||||
"DURATION of 60m is PT1H"
|
||||
(ev-ic-line lines "DURATION")
|
||||
"DURATION:PT1H")
|
||||
(ev-ic-check!
|
||||
"a one-off event has no RRULE"
|
||||
(ev-ic-line lines "RRULE")
|
||||
nil)))
|
||||
(ev-ic-check!
|
||||
"30m duration is PT30M"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote e)
|
||||
(ev-dt 2026 1 1 9 0)
|
||||
30
|
||||
nil
|
||||
1))
|
||||
"DURATION")
|
||||
"DURATION:PT30M")
|
||||
(ev-ic-check!
|
||||
"90m duration is PT1H30M"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote e)
|
||||
(ev-dt 2026 1 1 9 0)
|
||||
90
|
||||
nil
|
||||
1))
|
||||
"DURATION")
|
||||
"DURATION:PT1H30M")
|
||||
(let
|
||||
((lines (ev/event->ical-lines (ev-event-full (quote yoga) (ev-dt 2026 6 1 18 0) 90 {:interval 2 :freq :weekly :until (ev-dt 2026 6 30 23 0) :byday (list 0 2)} 20 (list (ev-dt 2026 6 8 18 0)) (list (ev-dt 2026 6 20 18 0))))))
|
||||
(do
|
||||
(ev-ic-check!
|
||||
"weekly RRULE serializes interval/until/byday in order"
|
||||
(ev-ic-line lines "RRULE")
|
||||
"RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20260630T230000Z;BYDAY=MO,WE")
|
||||
(ev-ic-check!
|
||||
"EXDATE line"
|
||||
(ev-ic-line lines "EXDATE")
|
||||
"EXDATE:20260608T180000Z")
|
||||
(ev-ic-check!
|
||||
"RDATE line"
|
||||
(ev-ic-line lines "RDATE")
|
||||
"RDATE:20260620T180000Z")))
|
||||
(ev-ic-check!
|
||||
"daily COUNT RRULE"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote d)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 5}
|
||||
1))
|
||||
"RRULE")
|
||||
"RRULE:FREQ=DAILY;COUNT=5")
|
||||
(ev-ic-check!
|
||||
"monthly nth-weekday BYDAY (2nd Tuesday)"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote b)
|
||||
(ev-dt 2026 1 13 9 0)
|
||||
60
|
||||
{:freq :monthly :byday (list {:ord 2 :wd 1})}
|
||||
5))
|
||||
"RRULE")
|
||||
"RRULE:FREQ=MONTHLY;BYDAY=2TU")
|
||||
(ev-ic-check!
|
||||
"monthly last-Friday BYDAY"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote b)
|
||||
(ev-dt 2026 1 30 9 0)
|
||||
60
|
||||
{:freq :monthly :byday (list {:ord -1 :wd 4})}
|
||||
5))
|
||||
"RRULE")
|
||||
"RRULE:FREQ=MONTHLY;BYDAY=-1FR")
|
||||
(ev-ic-check!
|
||||
"monthly BYMONTHDAY (incl. negative)"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote b)
|
||||
(ev-dt 2026 1 15 9 0)
|
||||
60
|
||||
{:bymonthday (list 15 -1) :freq :monthly}
|
||||
5))
|
||||
"RRULE")
|
||||
"RRULE:FREQ=MONTHLY;BYMONTHDAY=15,-1")
|
||||
(ev-ic-check!
|
||||
"all seven weekday tokens map correctly"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote w)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :weekly :byday (list 0 1 2 3 4 5 6)}
|
||||
1))
|
||||
"RRULE")
|
||||
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU")
|
||||
(let
|
||||
((cal (ev/events->ical-lines (list (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 nil 1) (ev-event (quote b) (ev-dt 2026 6 2 9 0) 30 nil 1)))))
|
||||
(do
|
||||
(ev-ic-check! "VCALENDAR opens" (first cal) "BEGIN:VCALENDAR")
|
||||
(ev-ic-check!
|
||||
"VCALENDAR declares VERSION"
|
||||
(ev-ic-line cal "VERSION")
|
||||
"VERSION:2.0")
|
||||
(ev-ic-check!
|
||||
"two events -> two VEVENT blocks"
|
||||
(len (filter (fn (l) (= l "BEGIN:VEVENT")) cal))
|
||||
2)
|
||||
(ev-ic-check!
|
||||
"VCALENDAR has exactly one closing line"
|
||||
(len (filter (fn (l) (= l "END:VCALENDAR")) cal))
|
||||
1)))
|
||||
(ev-ic-check!
|
||||
"render joins lines with CRLF"
|
||||
(ev/ical-render
|
||||
(list "BEGIN:VCALENDAR" "VERSION:2.0" "END:VCALENDAR"))
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR"))))
|
||||
|
||||
;; ---- import + round-trip ----
|
||||
|
||||
;; The occurrence starts an event expands to over a fixed window.
|
||||
(define
|
||||
ev-ic-starts
|
||||
(fn
|
||||
(ev)
|
||||
(map (fn (o) (get o :start)) (ev-expand ev (ev-date 2026 1 1) (ev-date 2027 1 1)))))
|
||||
|
||||
;; Round-trip an event through export then import; true if both expand alike.
|
||||
(define
|
||||
ev-ic-roundtrips?
|
||||
(fn
|
||||
(ev)
|
||||
(= (ev-ic-starts ev) (ev-ic-starts (ev/ical-lines->event (ev/event->ical-lines ev))))))
|
||||
|
||||
(define
|
||||
ev-ic-rt-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
;; ---- field parsers ----
|
||||
(ev-ic-check! "parse DTSTART" (ev-ical-parse-dt "20260601T180000Z") (ev-dt 2026 6 1 18 0))
|
||||
(ev-ic-check! "parse DURATION PT1H30M" (ev-ical-parse-duration "PT1H30M") 90)
|
||||
(ev-ic-check! "parse DURATION PT1H" (ev-ical-parse-duration "PT1H") 60)
|
||||
(ev-ic-check! "parse DURATION PT30M" (ev-ical-parse-duration "PT30M") 30)
|
||||
(ev-ic-check! "parse plain BYDAY token" (ev-ical-parse-byday-token "MO") 0)
|
||||
(ev-ic-check! "parse ordinal BYDAY token" (ev-ical-parse-byday-token "2TU") {:ord 2 :wd 1})
|
||||
(ev-ic-check! "parse last-weekday BYDAY token" (ev-ical-parse-byday-token "-1FR") {:ord -1 :wd 4})
|
||||
|
||||
;; ---- imported event basic fields ----
|
||||
(let
|
||||
((ev (ev/ical-lines->event (ev/event->ical-lines (ev-event (quote yoga) (ev-dt 2026 6 1 18 0) 90 nil 1)))))
|
||||
(do
|
||||
(ev-ic-check! "imported id is a symbol" (get ev :id) (quote yoga))
|
||||
(ev-ic-check! "imported dtstart" (get ev :dtstart) (ev-dt 2026 6 1 18 0))
|
||||
(ev-ic-check! "imported duration" (get ev :duration) 90)))
|
||||
|
||||
;; ---- round-trips preserve the occurrence set ----
|
||||
(ev-ic-check!
|
||||
"round-trip: one-off event"
|
||||
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 6 10 14 0) 60 nil 1))
|
||||
true)
|
||||
(ev-ic-check!
|
||||
"round-trip: daily COUNT"
|
||||
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 5} 1))
|
||||
true)
|
||||
(ev-ic-check!
|
||||
"round-trip: weekly interval/until/byday + exdate + rdate"
|
||||
(ev-ic-roundtrips?
|
||||
(ev-event-full
|
||||
(quote a)
|
||||
(ev-dt 2026 6 1 18 0)
|
||||
90
|
||||
{:freq :weekly :interval 2 :byday (list 0 2) :until (ev-dt 2026 6 30 23 0)}
|
||||
20
|
||||
(list (ev-dt 2026 6 8 18 0))
|
||||
(list (ev-dt 2026 6 20 18 0))))
|
||||
true)
|
||||
(ev-ic-check!
|
||||
"round-trip: monthly nth-weekday"
|
||||
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 1 13 9 0) 60 {:freq :monthly :byday (list {:ord 2 :wd 1})} 1))
|
||||
true)
|
||||
(ev-ic-check!
|
||||
"round-trip: monthly bymonthday"
|
||||
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 1 15 9 0) 60 {:freq :monthly :bymonthday (list 15 -1)} 1))
|
||||
true)
|
||||
|
||||
;; ---- parse a VCALENDAR with several events ----
|
||||
(let
|
||||
((cal
|
||||
(ev/events->ical-lines
|
||||
(list
|
||||
(ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)
|
||||
(ev-event (quote b) (ev-dt 2026 6 2 10 0) 60 nil 1)))))
|
||||
(let
|
||||
((events (ev/parse-vcalendar cal)))
|
||||
(do
|
||||
(ev-ic-check! "VCALENDAR parses both events" (len events) 2)
|
||||
(ev-ic-check! "first event id" (get (first events) :id) (quote a))
|
||||
(ev-ic-check! "second event id" (get (first (rest events)) :id) (quote b))
|
||||
(ev-ic-check!
|
||||
"parsed events expand correctly"
|
||||
(ev-ic-starts (first events))
|
||||
(ev-ic-starts (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)))))))))
|
||||
|
||||
;; ---- timezone-aware export (TZID + VTIMEZONE) ----
|
||||
(define
|
||||
ev-ic-find
|
||||
(fn
|
||||
(lines pfx)
|
||||
(cond
|
||||
((empty? lines) nil)
|
||||
((ev-ic-prefix? (first lines) pfx) (first lines))
|
||||
(else (ev-ic-find (rest lines) pfx)))))
|
||||
|
||||
(define ev-ic-count (fn (lines x) (len (filter (fn (l) (= l x)) lines))))
|
||||
|
||||
(define
|
||||
ev-ic-index
|
||||
(fn
|
||||
(lines x)
|
||||
(cond
|
||||
((empty? lines) -1)
|
||||
((= (first lines) x) 0)
|
||||
(else
|
||||
(let ((r (ev-ic-index (rest lines) x))) (if (< r 0) -1 (+ 1 r)))))))
|
||||
|
||||
(define
|
||||
ev-ic-tz-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
;; a tz event's DTSTART is local wall-clock with a TZID parameter
|
||||
(ev-ic-check!
|
||||
"tz event DTSTART uses TZID + local wall-clock (not UTC)"
|
||||
(ev-ic-find (ev/event->ical-lines (ev-event-tz (quote w) (ev-dt 2026 7 15 18 0) 60 nil 1 ev-tz-london)) "DTSTART")
|
||||
"DTSTART;TZID=Europe/London:20260715T180000")
|
||||
(ev-ic-check!
|
||||
"a non-tz event still uses a UTC Z stamp"
|
||||
(ev-ic-find (ev/event->ical-lines (ev-event (quote n) (ev-dt 2026 7 15 18 0) 60 nil 1)) "DTSTART")
|
||||
"DTSTART:20260715T180000Z")
|
||||
;; UNTIL stays UTC even for a TZID event (RFC 5545)
|
||||
(ev-ic-check!
|
||||
"tz event RRULE UNTIL is still UTC"
|
||||
(ev-ic-find
|
||||
(ev/event->ical-lines
|
||||
(ev-event-tz (quote s) (ev-dt 2026 6 1 18 0) 60 {:freq :weekly :byday (list 0) :until (ev-dt 2026 6 30 23 0)} 1 ev-tz-london))
|
||||
"RRULE")
|
||||
"RRULE:FREQ=WEEKLY;UNTIL=20260630T220000Z;BYDAY=MO")
|
||||
;; EXDATE matches the DTSTART form (TZID + local)
|
||||
(ev-ic-check!
|
||||
"tz event EXDATE uses TZID + local"
|
||||
(ev-ic-find
|
||||
(ev/event->ical-lines
|
||||
(assoc
|
||||
(ev-event-tz (quote s) (ev-dt 2026 7 1 18 0) 60 {:freq :daily :count 3} 1 ev-tz-london)
|
||||
:exdate
|
||||
(list (ev-dt 2026 7 2 18 0))))
|
||||
"EXDATE")
|
||||
"EXDATE;TZID=Europe/London:20260702T180000")
|
||||
|
||||
;; ---- VTIMEZONE block ----
|
||||
(let
|
||||
((vtz (ev-ical-vtimezone ev-tz-london)))
|
||||
(do
|
||||
(ev-ic-check! "VTIMEZONE names the zone" (ev-ic-find vtz "TZID") "TZID:Europe/London")
|
||||
(ev-ic-check! "DAYLIGHT transitions GMT->BST" (ev-ic-find vtz "TZOFFSETTO:+0100") "TZOFFSETTO:+0100")
|
||||
(ev-ic-check! "DAYLIGHT rule is last Sunday of March" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=3") "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU")
|
||||
(ev-ic-check! "STANDARD rule is last Sunday of October" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=10") "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU")))
|
||||
(let
|
||||
((vtz (ev-ical-vtimezone ev-tz-paris)))
|
||||
(do
|
||||
(ev-ic-check! "Paris DAYLIGHT goes to +0200 (CEST)" (ev-ic-find vtz "TZOFFSETTO:+0200") "TZOFFSETTO:+0200")
|
||||
(ev-ic-check! "Paris STANDARD goes to +0100 (CET)" (ev-ic-find vtz "TZOFFSETTO:+0100") "TZOFFSETTO:+0100")))
|
||||
|
||||
;; ---- VCALENDAR carries one VTIMEZONE per distinct zone ----
|
||||
(let
|
||||
((cal (ev/events->ical-lines (list (ev-event-tz (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1 ev-tz-london)))))
|
||||
(do
|
||||
(ev-ic-check! "VCALENDAR includes the referenced VTIMEZONE" (ev-ic-count cal "BEGIN:VTIMEZONE") 1)
|
||||
(ev-ic-check! "VTIMEZONE precedes the VEVENT" (< (ev-ic-index cal "BEGIN:VTIMEZONE") (ev-ic-index cal "BEGIN:VEVENT")) true)))
|
||||
(ev-ic-check!
|
||||
"two events in the same zone share one VTIMEZONE"
|
||||
(ev-ic-count
|
||||
(ev/events->ical-lines
|
||||
(list
|
||||
(ev-event-tz (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1 ev-tz-london)
|
||||
(ev-event-tz (quote b) (ev-dt 2026 6 2 9 0) 60 nil 1 ev-tz-london)))
|
||||
"BEGIN:VTIMEZONE")
|
||||
1)
|
||||
(ev-ic-check!
|
||||
"events in two zones get two VTIMEZONEs"
|
||||
(ev-ic-count
|
||||
(ev/events->ical-lines
|
||||
(list
|
||||
(ev-event-tz (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1 ev-tz-london)
|
||||
(ev-event-tz (quote b) (ev-dt 2026 6 2 9 0) 60 nil 1 ev-tz-paris)))
|
||||
"BEGIN:VTIMEZONE")
|
||||
2)
|
||||
(ev-ic-check!
|
||||
"a non-tz-only calendar has no VTIMEZONE"
|
||||
(ev-ic-count (ev/events->ical-lines (list (ev-event (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1))) "BEGIN:VTIMEZONE")
|
||||
0)
|
||||
|
||||
;; ---- import tolerates the TZID parameter ----
|
||||
(ev-ic-check!
|
||||
"import parses DTSTART;TZID local time"
|
||||
(get
|
||||
(ev/ical-lines->event (ev/event->ical-lines (ev-event-tz (quote a) (ev-dt 2026 7 15 18 0) 60 nil 1 ev-tz-london)))
|
||||
:dtstart)
|
||||
(ev-dt 2026 7 15 18 0)))))
|
||||
|
||||
(define
|
||||
ev-ical-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-ic-pass 0)
|
||||
(set! ev-ic-fail 0)
|
||||
(set! ev-ic-failures (list))
|
||||
(ev-ic-run-all!)
|
||||
(ev-ic-rt-run-all!)
|
||||
(ev-ic-tz-run-all!)
|
||||
{:failures ev-ic-failures :total (+ ev-ic-pass ev-ic-fail) :passed ev-ic-pass :failed ev-ic-fail})))
|
||||
@@ -1,144 +0,0 @@
|
||||
;; lib/events/tests/integration.sx — end-to-end pipeline: derive notification
|
||||
;; messages (SX) -> deliver them through the durable notify flow (Scheme).
|
||||
|
||||
(define ev-it-pass 0)
|
||||
(define ev-it-fail 0)
|
||||
(define ev-it-failures (list))
|
||||
|
||||
(define
|
||||
ev-it-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-it-pass (+ ev-it-pass 1))
|
||||
(do
|
||||
(set! ev-it-fail (+ ev-it-fail 1))
|
||||
(append!
|
||||
ev-it-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define ev-it-status (fn (outcome) (first outcome)))
|
||||
(define ev-it-id (fn (outcome) (first (rest outcome))))
|
||||
|
||||
;; A store with a weekly class; nia + ola booked into the first occurrence.
|
||||
(define
|
||||
ev-it-setup
|
||||
(fn
|
||||
(b)
|
||||
(let
|
||||
((store (ev/schedule (ev/empty) (quote yoga) (ev-dt 2026 6 1 18 0) 60 {:freq :weekly :count 4 :byday (list 0 2)} 20)))
|
||||
(let
|
||||
((occ1 (ev-occ (quote yoga) (ev-dt 2026 6 1 18 0) 60)))
|
||||
(do
|
||||
(ev/book-occ! b store (quote nia) occ1)
|
||||
(ev/book-occ! b store (quote ola) occ1)
|
||||
store)))))
|
||||
|
||||
(define
|
||||
ev-it-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(let
|
||||
((store (ev-it-setup b)))
|
||||
(let
|
||||
((reminders (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60)))
|
||||
(let
|
||||
((msgs (map ev/reminder->msg reminders))
|
||||
(outcomes
|
||||
(ev/deliver-messages
|
||||
(map ev/reminder->msg reminders)
|
||||
ev-notify-ok-transport
|
||||
3
|
||||
20)))
|
||||
(do
|
||||
(ev-it-check!
|
||||
"every booked attendee's reminder is delivered"
|
||||
(map ev-it-status outcomes)
|
||||
(list "delivered" "delivered"))
|
||||
(ev-it-check!
|
||||
"one delivery per derived reminder"
|
||||
(len outcomes)
|
||||
(len msgs))
|
||||
(ev-it-check!
|
||||
"delivered ids match the reminder idempotency keys"
|
||||
(map ev-it-id outcomes)
|
||||
(map (fn (r) (get r :id)) reminders)))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(let
|
||||
((store (ev-it-setup b)))
|
||||
(let
|
||||
((msgs (map ev/reminder->msg (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60))))
|
||||
(ev-it-check!
|
||||
"a permanently-failing transport reports failed deliveries"
|
||||
(map
|
||||
ev-it-status
|
||||
(ev/deliver-messages
|
||||
msgs
|
||||
"(lambda (k p) (list (quote retry) (quote down)))"
|
||||
2
|
||||
20))
|
||||
(list "failed" "failed")))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "occ" 1 (quote nia))
|
||||
(ev/waitlist! b "occ" 1 (quote ola))
|
||||
(ev/cancel-promote! b "occ" 1 (quote nia))
|
||||
(let
|
||||
((promoted (ev/notify-of-kind (ev/booking-notifications b "occ" (quote yoga)) :promoted)))
|
||||
(let
|
||||
((outcomes (ev/deliver-messages (map ev/booking-notify->msg promoted) ev-notify-ok-transport 3 12)))
|
||||
(do
|
||||
(ev-it-check!
|
||||
"the waitlist-promotion notification is delivered"
|
||||
(map ev-it-status outcomes)
|
||||
(list "delivered"))
|
||||
(ev-it-check!
|
||||
"exactly one promotion was delivered"
|
||||
(len outcomes)
|
||||
1))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(let
|
||||
((ev (ev-event (quote yoga) (ev-dt 2026 6 1 18 0) 60 {:freq :daily :count 3} 20)))
|
||||
(do
|
||||
(ev/book-occ!
|
||||
b
|
||||
(ev/add-event (ev/empty) ev)
|
||||
(quote nia)
|
||||
(ev-occ
|
||||
(quote yoga)
|
||||
(ev-dt 2026 6 2 18 0)
|
||||
60))
|
||||
(let
|
||||
((moved (ev-with-override ev (ev-dt 2026 6 2 18 0) (ev-dt 2026 6 2 20 0) 60)))
|
||||
(let
|
||||
((outcomes (ev/deliver-messages (map ev/reschedule-notify->msg (ev/reschedule-notifications b moved)) ev-notify-ok-transport 3 12)))
|
||||
(ev-it-check!
|
||||
"the reschedule notice is delivered to the booked attendee"
|
||||
(map ev-it-status outcomes)
|
||||
(list "delivered")))))))
|
||||
(ev-it-check!
|
||||
"delivering no messages yields no outcomes"
|
||||
(ev/deliver-messages
|
||||
(list)
|
||||
ev-notify-ok-transport
|
||||
3
|
||||
12)
|
||||
(list)))))
|
||||
|
||||
(define
|
||||
ev-integration-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-it-pass 0)
|
||||
(set! ev-it-fail 0)
|
||||
(set! ev-it-failures (list))
|
||||
(ev-it-run-all!)
|
||||
{:failures ev-it-failures :total (+ ev-it-pass ev-it-fail) :passed ev-it-pass :failed ev-it-fail})))
|
||||
@@ -1,77 +0,0 @@
|
||||
;; 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})))
|
||||
@@ -1,276 +0,0 @@
|
||||
;; 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)))))))))
|
||||
|
||||
;; ---- reschedule notifications ----
|
||||
(define
|
||||
ev-rm-rs-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((b (persist/open))
|
||||
(ev (ev-event (quote yoga) (ev-dt 2026 6 1 9 0) 60 {:freq :daily :count 3} 20)))
|
||||
(let
|
||||
((occ2 (ev-occ (quote yoga) (ev-dt 2026 6 2 9 0) 60)))
|
||||
(do
|
||||
(ev/book-occ! b (ev/add-event (ev/empty) ev) (quote nia) occ2)
|
||||
(ev/book-occ! b (ev/add-event (ev/empty) ev) (quote ola) occ2)
|
||||
;; reschedule the Jun 2 occurrence to 14:00 / 90 min
|
||||
(let
|
||||
((moved (ev-with-override ev (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0) 90)))
|
||||
(let
|
||||
((ns (ev/reschedule-notifications b moved)))
|
||||
(do
|
||||
(ev-rm-check!
|
||||
"every booked attendee is notified of the reschedule"
|
||||
(map (fn (n) (get n :recipient)) ns)
|
||||
(list (quote nia) (quote ola)))
|
||||
(ev-rm-check!
|
||||
"reschedule carries old and new start"
|
||||
(list (get (first ns) :old-start) (get (first ns) :new-start))
|
||||
(list (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0)))
|
||||
(ev-rm-check!
|
||||
"reschedule carries the new duration"
|
||||
(get (first ns) :new-duration)
|
||||
90)
|
||||
(ev-rm-check!
|
||||
"reschedule idempotency key encodes original key + new start"
|
||||
(get (first ns) :id)
|
||||
(str (ev-occ-key occ2) "/reschedule/" (ev-dt 2026 6 2 14 0)))
|
||||
(ev-rm-check!
|
||||
"reschedule projects to notify wire shape"
|
||||
(ev/reschedule-notify->msg (first ns))
|
||||
(list
|
||||
(str (ev-occ-key occ2) "/reschedule/" (ev-dt 2026 6 2 14 0))
|
||||
(quote nia)
|
||||
(list :rescheduled (quote yoga) (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0)))))))
|
||||
;; an override on an occurrence nobody booked notifies no one
|
||||
(let
|
||||
((moved2 (ev-with-override ev (ev-dt 2026 6 3 9 0) (ev-dt 2026 6 3 10 0) 60)))
|
||||
(ev-rm-check!
|
||||
"rescheduling an unbooked occurrence notifies no one"
|
||||
(len (ev/reschedule-notifications b moved2))
|
||||
0))
|
||||
;; an event with no overrides yields no reschedule notifications
|
||||
(ev-rm-check!
|
||||
"event without overrides has no reschedule notifications"
|
||||
(len (ev/reschedule-notifications b ev))
|
||||
0))))))
|
||||
|
||||
(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!)
|
||||
(ev-rm-rs-run-all!)
|
||||
{:failures ev-rm-failures :total (+ ev-rm-pass ev-rm-fail) :passed ev-rm-pass :failed ev-rm-fail})))
|
||||
@@ -1,252 +0,0 @@
|
||||
;; 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})))
|
||||
@@ -1,173 +0,0 @@
|
||||
;; lib/events/tests/timezone.sx — timezones + DST.
|
||||
|
||||
(define ev-tz-pass 0)
|
||||
(define ev-tz-fail 0)
|
||||
(define ev-tz-failures (list))
|
||||
|
||||
(define
|
||||
ev-tz-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-tz-pass (+ ev-tz-pass 1))
|
||||
(do
|
||||
(set! ev-tz-fail (+ ev-tz-fail 1))
|
||||
(append!
|
||||
ev-tz-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Wall-clock (civil + minute-of-day) an occurrence's UTC start maps to in a tz.
|
||||
(define
|
||||
ev-tz-local-of
|
||||
(fn
|
||||
(tz utc-dt)
|
||||
(let
|
||||
((l (ev-tz-utc->local tz utc-dt)))
|
||||
(list (ev-dt->civil l) (ev-dt-tod l)))))
|
||||
|
||||
(define
|
||||
ev-tz-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((nyc (ev-tz-fixed "EST" -300)))
|
||||
(do
|
||||
(ev-tz-check!
|
||||
"fixed zone: utc -> local subtracts 5h"
|
||||
(ev-tz-utc->local
|
||||
nyc
|
||||
(ev-dt 2026 1 1 17 0))
|
||||
(ev-dt 2026 1 1 12 0))
|
||||
(ev-tz-check!
|
||||
"fixed zone: local -> utc adds 5h back"
|
||||
(ev-tz-local->utc
|
||||
nyc
|
||||
(ev-dt 2026 1 1 12 0))
|
||||
(ev-dt 2026 1 1 17 0))
|
||||
(ev-tz-check!
|
||||
"UTC zone is identity"
|
||||
(ev-tz-local->utc
|
||||
ev-tz-utc
|
||||
(ev-dt 2026 6 1 9 0))
|
||||
(ev-dt 2026 6 1 9 0))))
|
||||
(ev-tz-check!
|
||||
"London winter offset is 0 (GMT)"
|
||||
(ev-tz-offset
|
||||
ev-tz-london
|
||||
(ev-dt 2026 1 15 12 0))
|
||||
0)
|
||||
(ev-tz-check!
|
||||
"London summer offset is 60 (BST)"
|
||||
(ev-tz-offset
|
||||
ev-tz-london
|
||||
(ev-dt 2026 7 15 12 0))
|
||||
60)
|
||||
(ev-tz-check!
|
||||
"Paris winter offset is 60 (CET)"
|
||||
(ev-tz-offset
|
||||
ev-tz-paris
|
||||
(ev-dt 2026 1 15 12 0))
|
||||
60)
|
||||
(ev-tz-check!
|
||||
"Paris summer offset is 120 (CEST)"
|
||||
(ev-tz-offset
|
||||
ev-tz-paris
|
||||
(ev-dt 2026 7 15 12 0))
|
||||
120)
|
||||
(ev-tz-check!
|
||||
"DST starts last Sunday of March"
|
||||
(ev-dt->civil
|
||||
(ev-tz-transition
|
||||
2026
|
||||
(ev-tz-rule 3 -1 6 60)))
|
||||
(list 2026 3 29))
|
||||
(ev-tz-check!
|
||||
"DST ends last Sunday of October"
|
||||
(ev-dt->civil
|
||||
(ev-tz-transition
|
||||
2026
|
||||
(ev-tz-rule 10 -1 6 60)))
|
||||
(list 2026 10 25))
|
||||
(ev-tz-check!
|
||||
"09:00 London in winter is 09:00 UTC"
|
||||
(ev-tz-local->utc
|
||||
ev-tz-london
|
||||
(ev-dt 2026 1 15 9 0))
|
||||
(ev-dt 2026 1 15 9 0))
|
||||
(ev-tz-check!
|
||||
"09:00 London in summer is 08:00 UTC"
|
||||
(ev-tz-local->utc
|
||||
ev-tz-london
|
||||
(ev-dt 2026 7 15 9 0))
|
||||
(ev-dt 2026 7 15 8 0))
|
||||
(ev-tz-check!
|
||||
"round trip utc -> local -> utc"
|
||||
(ev-tz-local->utc
|
||||
ev-tz-london
|
||||
(ev-tz-utc->local
|
||||
ev-tz-london
|
||||
(ev-dt 2026 7 15 8 0)))
|
||||
(ev-dt 2026 7 15 8 0))
|
||||
(let
|
||||
((ev (ev-event-tz (quote standup) (ev-dt 2026 3 27 9 0) 60 {:freq :daily :count 5} 10 ev-tz-london)))
|
||||
(let
|
||||
((occs (ev-expand ev (ev-date 2026 3 1) (ev-date 2026 4 5))))
|
||||
(do
|
||||
(ev-tz-check!
|
||||
"daily occurrences shift in UTC across the DST boundary"
|
||||
(map (fn (o) (ev-dt-tod (get o :start))) occs)
|
||||
(list 540 540 480 480 480))
|
||||
(ev-tz-check!
|
||||
"but every occurrence stays 09:00 local wall-clock"
|
||||
(map
|
||||
(fn
|
||||
(o)
|
||||
(first
|
||||
(rest (ev-tz-local-of ev-tz-london (get o :start)))))
|
||||
occs)
|
||||
(list 540 540 540 540 540))
|
||||
(ev-tz-check!
|
||||
"occurrence dates are stable in local time"
|
||||
(map
|
||||
(fn
|
||||
(o)
|
||||
(ev-civ-d
|
||||
(first (ev-tz-local-of ev-tz-london (get o :start)))))
|
||||
occs)
|
||||
(list 27 28 29 30 31)))))
|
||||
(let
|
||||
((wk (ev-event-tz (quote class) (ev-dt 2026 3 23 18 0) 90 {:freq :weekly :byday (list 0)} 5 ev-tz-london)))
|
||||
(let
|
||||
((occs (ev-expand wk (ev-date 2026 3 1) (ev-date 2026 4 20))))
|
||||
(ev-tz-check!
|
||||
"weekly Monday 18:00 London stays 18:00 local each week"
|
||||
(map
|
||||
(fn
|
||||
(o)
|
||||
(first (rest (ev-tz-local-of ev-tz-london (get o :start)))))
|
||||
occs)
|
||||
(list 1080 1080 1080 1080))))
|
||||
(let
|
||||
((plain (ev-event (quote p) (ev-dt 2026 3 27 9 0) 60 {:freq :daily :count 3} 1)))
|
||||
(ev-tz-check!
|
||||
"plain event expands naively (no UTC shift)"
|
||||
(map
|
||||
(fn (o) (ev-dt-tod (get o :start)))
|
||||
(ev-expand
|
||||
plain
|
||||
(ev-date 2026 3 1)
|
||||
(ev-date 2026 4 5)))
|
||||
(list 540 540 540))))))
|
||||
|
||||
(define
|
||||
ev-timezone-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-tz-pass 0)
|
||||
(set! ev-tz-fail 0)
|
||||
(set! ev-tz-failures (list))
|
||||
(ev-tz-run-all!)
|
||||
{:failures ev-tz-failures :total (+ ev-tz-pass ev-tz-fail) :passed ev-tz-pass :failed ev-tz-fail})))
|
||||
@@ -1,101 +0,0 @@
|
||||
;; 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}))))))
|
||||
@@ -1,131 +0,0 @@
|
||||
;; lib/events/timezone.sx — timezones + DST for the calendar.
|
||||
;;
|
||||
;; Datetimes in calendar.sx are naive epoch-minutes (wall clock). A timezone
|
||||
;; maps between wall-clock LOCAL time and absolute UTC. An event is authored in
|
||||
;; local time + a tz; recurrence is expanded in local time (so a "09:00 weekly"
|
||||
;; meeting stays 09:00 across a DST change), then each occurrence is converted
|
||||
;; to UTC for storage/comparison.
|
||||
;;
|
||||
;; Offset convention: offset = local - utc (minutes). London summer (BST) = +60.
|
||||
;; UTC = local - offset; local = utc + offset.
|
||||
;;
|
||||
;; Two kinds of zone, no IANA database:
|
||||
;; :fixed — a constant offset.
|
||||
;; :dst — std/dst offsets + two transition rules. Transitions are given in
|
||||
;; UTC (EU zones all switch at 01:00 UTC), so the offset at any UTC
|
||||
;; instant is a direct range check; no recursion. Northern-hemisphere
|
||||
;; ordering (dst-start < dst-end within a year) is assumed.
|
||||
;;
|
||||
;; Requires calendar.sx (ev-dt, ev-days-from-civil, ev-civil-from-days,
|
||||
;; ev-civ-y, ev-floor-div, ev-resolve-nth-weekday).
|
||||
|
||||
;; A DST transition rule: the ord-th weekday `wd` (0=Mon..6=Sun) of `month`, at
|
||||
;; `time` minutes-of-day UTC. EU: last Sunday (ord -1, wd 6) at 01:00 UTC.
|
||||
(define ev-tz-rule (fn (month ord wd time) {:ord ord :wd wd :month month :time time}))
|
||||
|
||||
(define ev-tz-fixed (fn (name offset) {:name name :offset offset :kind :fixed}))
|
||||
|
||||
(define ev-tz-dst (fn (name std dst start-rule end-rule) {:name name :kind :dst :dst-end end-rule :dst-start start-rule :std-offset std :dst-offset dst}))
|
||||
|
||||
;; Standard (winter) offset — the initial guess when inverting local -> utc.
|
||||
(define
|
||||
ev-tz-std-offset
|
||||
(fn
|
||||
(tz)
|
||||
(if (= (get tz :kind) :fixed) (get tz :offset) (get tz :std-offset))))
|
||||
|
||||
;; The UTC instant (epoch-minutes) of a transition rule in a given year.
|
||||
(define
|
||||
ev-tz-transition
|
||||
(fn
|
||||
(year rule)
|
||||
(let
|
||||
((day (ev-resolve-nth-weekday year (get rule :month) (get rule :ord) (get rule :wd))))
|
||||
(+
|
||||
(* (ev-days-from-civil year (get rule :month) day) 1440)
|
||||
(get rule :time)))))
|
||||
|
||||
;; The offset (minutes) in effect at a UTC instant.
|
||||
(define
|
||||
ev-tz-offset
|
||||
(fn
|
||||
(tz utc-dt)
|
||||
(cond
|
||||
((= (get tz :kind) :fixed) (get tz :offset))
|
||||
((= (get tz :kind) :dst)
|
||||
(let
|
||||
((year (ev-civ-y (ev-civil-from-days (ev-floor-div utc-dt 1440)))))
|
||||
(let
|
||||
((start (ev-tz-transition year (get tz :dst-start)))
|
||||
(end (ev-tz-transition year (get tz :dst-end))))
|
||||
(if
|
||||
(and (>= utc-dt start) (< utc-dt end))
|
||||
(get tz :dst-offset)
|
||||
(get tz :std-offset)))))
|
||||
(else 0))))
|
||||
|
||||
;; UTC instant -> local wall-clock.
|
||||
(define
|
||||
ev-tz-utc->local
|
||||
(fn (tz utc-dt) (+ utc-dt (ev-tz-offset tz utc-dt))))
|
||||
|
||||
;; Local wall-clock -> UTC instant. The offset depends on the instant, so we
|
||||
;; guess with the standard offset and refine once (correct except within the
|
||||
;; one-hour DST gap/overlap, where it resolves to the pre-transition offset).
|
||||
(define
|
||||
ev-tz-local->utc
|
||||
(fn
|
||||
(tz local-dt)
|
||||
(let
|
||||
((utc1 (- local-dt (ev-tz-offset tz (- local-dt (ev-tz-std-offset tz))))))
|
||||
(- local-dt (ev-tz-offset tz utc1)))))
|
||||
|
||||
;; ---- predefined zones ----
|
||||
(define ev-tz-utc (ev-tz-fixed "UTC" 0))
|
||||
(define
|
||||
ev-tz-london
|
||||
(ev-tz-dst
|
||||
"Europe/London"
|
||||
0
|
||||
60
|
||||
(ev-tz-rule 3 -1 6 60)
|
||||
(ev-tz-rule 10 -1 6 60)))
|
||||
(define
|
||||
ev-tz-paris
|
||||
(ev-tz-dst
|
||||
"Europe/Paris"
|
||||
60
|
||||
120
|
||||
(ev-tz-rule 3 -1 6 60)
|
||||
(ev-tz-rule 10 -1 6 60)))
|
||||
|
||||
;; ---- tz-aware event expansion ----
|
||||
|
||||
;; An event authored in local time + a tz. dtstart-local / rrule / exceptions
|
||||
;; are all wall-clock in `tz`; expansion converts each occurrence to UTC.
|
||||
(define
|
||||
ev-event-tz
|
||||
(fn (id dtstart-local duration rrule capacity tz) {:id id :duration duration :dtstart dtstart-local :rrule rrule :capacity capacity :tz tz}))
|
||||
|
||||
;; Expand a tz-aware event over a UTC window. Local recurrence is expanded over
|
||||
;; a window widened by a day each side (to catch occurrences whose UTC lands in
|
||||
;; range), converted to UTC, then filtered to [win-start, win-end].
|
||||
(define
|
||||
ev-expand-tz
|
||||
(fn
|
||||
(event tz win-start win-end)
|
||||
(let
|
||||
((local-ws (- (ev-tz-utc->local tz win-start) 1440))
|
||||
(local-we (+ (ev-tz-utc->local tz win-end) 1440)))
|
||||
(let
|
||||
((local-occs (ev-expand-naive event local-ws local-we)))
|
||||
(let
|
||||
((utc-occs (map (fn (o) (let ((u (ev-tz-local->utc tz (get o :start))) (dur (- (get o :end) (get o :start)))) {:id (get o :id) :start u :end (+ u dur)})) local-occs)))
|
||||
(ev-sort-occs
|
||||
(filter
|
||||
(fn
|
||||
(o)
|
||||
(and
|
||||
(>= (get o :start) win-start)
|
||||
(<= (get o :start) win-end)))
|
||||
utc-occs)))))))
|
||||
36
lib/identity/api.sx
Normal file
36
lib/identity/api.sx
Normal file
File diff suppressed because one or more lines are too long
27
lib/identity/audit.sx
Normal file
27
lib/identity/audit.sx
Normal file
@@ -0,0 +1,27 @@
|
||||
;; 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)))
|
||||
29
lib/identity/cache.sx
Normal file
29
lib/identity/cache.sx
Normal file
@@ -0,0 +1,29 @@
|
||||
;; 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)))
|
||||
28
lib/identity/clients.sx
Normal file
28
lib/identity/clients.sx
Normal file
@@ -0,0 +1,28 @@
|
||||
;; 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)))
|
||||
215
lib/identity/conformance.sh
Executable file
215
lib/identity/conformance.sh
Executable file
@@ -0,0 +1,215 @@
|
||||
#!/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
|
||||
34
lib/identity/delegation.sx
Normal file
34
lib/identity/delegation.sx
Normal file
@@ -0,0 +1,34 @@
|
||||
;; 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)))
|
||||
33
lib/identity/device.sx
Normal file
33
lib/identity/device.sx
Normal file
@@ -0,0 +1,33 @@
|
||||
;; 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)))
|
||||
30
lib/identity/federation.sx
Normal file
30
lib/identity/federation.sx
Normal file
@@ -0,0 +1,30 @@
|
||||
;; 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)))
|
||||
31
lib/identity/membership.sx
Normal file
31
lib/identity/membership.sx
Normal file
@@ -0,0 +1,31 @@
|
||||
;; 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)))
|
||||
37
lib/identity/oauth.sx
Normal file
37
lib/identity/oauth.sx
Normal file
File diff suppressed because one or more lines are too long
22
lib/identity/registry.sx
Normal file
22
lib/identity/registry.sx
Normal file
@@ -0,0 +1,22 @@
|
||||
;; identity/registry.sx — routes sessions by id and by (subject, client).
|
||||
;;
|
||||
;; The registry is the directory that makes SSO possible: one subject can
|
||||
;; hold many sessions (one per client), and the OAuth machine asks it the
|
||||
;; single question that drives silent login — \"is there a live session
|
||||
;; for this subject + this client?\". It stores (SessionId, Subject,
|
||||
;; Client, Pid) rows and answers:
|
||||
;;
|
||||
;; whereis_session(Id) -> {ok, Pid} | {error, not_found}
|
||||
;; lookup(Subject, Client) -> {ok, Pid} | {error, not_found} (SSO probe)
|
||||
;; sessions_for(Subject) -> {ok, [SessionId, ...]} (fan-out)
|
||||
;;
|
||||
;; The registry only routes — it holds no grant state and decides nothing.
|
||||
;; Liveness of the routed-to session is that session process's own affair.
|
||||
|
||||
(define
|
||||
identity-registry-source
|
||||
"-module(identity_registry).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n register(Reg, SessionId, Subject, Client, Pid) ->\n Reg ! {register, SessionId, Subject, Client, Pid, self()},\n receive {registry_reply, R} -> R end.\n\n whereis_session(Reg, SessionId) ->\n Reg ! {whereis_session, SessionId, self()},\n receive {registry_reply, R} -> R end.\n\n lookup(Reg, Subject, Client) ->\n Reg ! {lookup, Subject, Client, self()},\n receive {registry_reply, R} -> R end.\n\n sessions_for(Reg, Subject) ->\n Reg ! {sessions_for, Subject, self()},\n receive {registry_reply, R} -> R end.\n\n deregister(Reg, SessionId) ->\n Reg ! {deregister, SessionId, self()},\n receive {registry_reply, R} -> R end.\n\n stop(Reg) ->\n Reg ! {stop, self()},\n receive {registry_reply, R} -> R end.\n\n loop(Entries) ->\n receive\n {register, SessionId, Subject, Client, Pid, From} ->\n From ! {registry_reply, ok},\n loop([{SessionId, Subject, Client, Pid} | remove_id(SessionId, Entries)]);\n {whereis_session, SessionId, From} ->\n From ! {registry_reply, find_id(SessionId, Entries)},\n loop(Entries);\n {lookup, Subject, Client, From} ->\n From ! {registry_reply, find_sc(Subject, Client, Entries)},\n loop(Entries);\n {sessions_for, Subject, From} ->\n From ! {registry_reply, {ok, collect_subject(Subject, Entries)}},\n loop(Entries);\n {deregister, SessionId, From} ->\n From ! {registry_reply, ok},\n loop(remove_id(SessionId, Entries));\n {stop, From} ->\n From ! {registry_reply, ok}\n end.\n\n find_id(_, []) -> {error, not_found};\n find_id(Id, [{Sid, _, _, Pid} | Rest]) ->\n case Sid =:= Id of\n true -> {ok, Pid};\n false -> find_id(Id, Rest)\n end.\n\n find_sc(_, _, []) -> {error, not_found};\n find_sc(Subject, Client, [{_, Su, Cl, Pid} | Rest]) ->\n case Su =:= Subject of\n true ->\n case Cl =:= Client of\n true -> {ok, Pid};\n false -> find_sc(Subject, Client, Rest)\n end;\n false -> find_sc(Subject, Client, Rest)\n end.\n\n collect_subject(_, []) -> [];\n collect_subject(Subject, [{Sid, Su, _, _} | Rest]) ->\n case Su =:= Subject of\n true -> [Sid | collect_subject(Subject, Rest)];\n false -> collect_subject(Subject, Rest)\n end.\n\n remove_id(_, []) -> [];\n remove_id(Id, [{Sid, Su, Cl, Pid} | Rest]) ->\n case Sid =:= Id of\n true -> remove_id(Id, Rest);\n false -> [{Sid, Su, Cl, Pid} | remove_id(Id, Rest)]\n end.")
|
||||
|
||||
(define
|
||||
identity-load-registry!
|
||||
(fn () (erlang-load-module identity-registry-source)))
|
||||
29
lib/identity/scoreboard.json
Normal file
29
lib/identity/scoreboard.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"language": "identity",
|
||||
"total_pass": 233,
|
||||
"total": 233,
|
||||
"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":11,"total":11,"status":"ok"}
|
||||
]
|
||||
}
|
||||
31
lib/identity/scoreboard.md
Normal file
31
lib/identity/scoreboard.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# identity-on-sx Scoreboard
|
||||
|
||||
**Total: 233 / 233 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 | 11 | 11 |
|
||||
|
||||
|
||||
Generated by `lib/identity/conformance.sh`.
|
||||
20
lib/identity/session.sx
Normal file
20
lib/identity/session.sx
Normal file
@@ -0,0 +1,20 @@
|
||||
;; 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)))
|
||||
102
lib/identity/tests/account.sx
Normal file
102
lib/identity/tests/account.sx
Normal file
@@ -0,0 +1,102 @@
|
||||
;; identity/tests/account.sx — the account-security surface: \"apps with
|
||||
;; access\" (grants_for / identity:grants) plus \"disconnect this app\"
|
||||
;; (revoke_app / identity:revoke_app). Completes the per-subject view+action
|
||||
;; pair alongside 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")
|
||||
|
||||
;; ── token-level revoke_app (\"disconnect this app\") ────────────────
|
||||
|
||||
(id-acct-test
|
||||
"revoke_app revokes all of a subject's grants for one client"
|
||||
(ida-ev
|
||||
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n identity_tokens:issue(R, alice, web, write),\n identity_tokens:issue(R, alice, cli, read),\n identity_tokens:revoke_app(R, alice, web),\n length(identity_tokens:grants_for(R, alice))")
|
||||
1)
|
||||
|
||||
(id-acct-test
|
||||
"revoke_app deactivates that client's tokens"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:revoke_app(R, alice, web),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(id-acct-test
|
||||
"revoke_app leaves another subject's same-client grant intact"
|
||||
(ida-ev
|
||||
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n identity_tokens:issue(R, bob, web, read),\n identity_tokens:revoke_app(R, alice, web),\n length(identity_tokens:grants_for(R, bob))")
|
||||
1)
|
||||
|
||||
;; ── facade-level grants + revoke_app ─────────────────────────────
|
||||
|
||||
(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
|
||||
"identity:revoke_app disconnects one app, leaving the rest"
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, mobile, read),\n identity:revoke_app(Svc, alice, web),\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)
|
||||
|
||||
(id-acct-test
|
||||
"revoke_app is audited as a revoke"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:revoke_app(Svc, alice, web),\n case identity:history(Svc, alice) of\n [login, issue, revoke] -> audited;\n Other -> Other\n end"))
|
||||
"audited")
|
||||
|
||||
(define
|
||||
id-acct-test-summary
|
||||
(str "account " id-acct-test-pass "/" id-acct-test-count))
|
||||
111
lib/identity/tests/api.sx
Normal file
111
lib/identity/tests/api.sx
Normal file
@@ -0,0 +1,111 @@
|
||||
;; 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))
|
||||
117
lib/identity/tests/audit.sx
Normal file
117
lib/identity/tests/audit.sx
Normal file
@@ -0,0 +1,117 @@
|
||||
;; 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))
|
||||
102
lib/identity/tests/cache.sx
Normal file
102
lib/identity/tests/cache.sx
Normal file
@@ -0,0 +1,102 @@
|
||||
;; 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))
|
||||
108
lib/identity/tests/clients.sx
Normal file
108
lib/identity/tests/clients.sx
Normal file
@@ -0,0 +1,108 @@
|
||||
;; 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))
|
||||
102
lib/identity/tests/delegation.sx
Normal file
102
lib/identity/tests/delegation.sx
Normal file
@@ -0,0 +1,102 @@
|
||||
;; 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))
|
||||
109
lib/identity/tests/device.sx
Normal file
109
lib/identity/tests/device.sx
Normal file
@@ -0,0 +1,109 @@
|
||||
;; 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))
|
||||
68
lib/identity/tests/dynreg.sx
Normal file
68
lib/identity/tests/dynreg.sx
Normal file
@@ -0,0 +1,68 @@
|
||||
;; 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))
|
||||
110
lib/identity/tests/exchange.sx
Normal file
110
lib/identity/tests/exchange.sx
Normal file
@@ -0,0 +1,110 @@
|
||||
;; 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))
|
||||
92
lib/identity/tests/expiry.sx
Normal file
92
lib/identity/tests/expiry.sx
Normal file
@@ -0,0 +1,92 @@
|
||||
;; 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))
|
||||
97
lib/identity/tests/facade.sx
Normal file
97
lib/identity/tests/facade.sx
Normal file
@@ -0,0 +1,97 @@
|
||||
;; 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))
|
||||
115
lib/identity/tests/federation.sx
Normal file
115
lib/identity/tests/federation.sx
Normal file
@@ -0,0 +1,115 @@
|
||||
;; 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))
|
||||
96
lib/identity/tests/grants.sx
Normal file
96
lib/identity/tests/grants.sx
Normal file
@@ -0,0 +1,96 @@
|
||||
;; 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))
|
||||
93
lib/identity/tests/introspect.sx
Normal file
93
lib/identity/tests/introspect.sx
Normal file
@@ -0,0 +1,93 @@
|
||||
;; 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))
|
||||
155
lib/identity/tests/membership.sx
Normal file
155
lib/identity/tests/membership.sx
Normal file
@@ -0,0 +1,155 @@
|
||||
;; 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))
|
||||
192
lib/identity/tests/oauth.sx
Normal file
192
lib/identity/tests/oauth.sx
Normal file
@@ -0,0 +1,192 @@
|
||||
;; 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))
|
||||
84
lib/identity/tests/par.sx
Normal file
84
lib/identity/tests/par.sx
Normal file
@@ -0,0 +1,84 @@
|
||||
;; 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))
|
||||
99
lib/identity/tests/registry.sx
Normal file
99
lib/identity/tests/registry.sx
Normal file
@@ -0,0 +1,99 @@
|
||||
;; identity/tests/registry.sx — routing by id and by (subject, client),
|
||||
;; SSO fan-out (one subject, many clients), and integration with live
|
||||
;; session processes routed through the registry.
|
||||
|
||||
(define id-registry-test-count 0)
|
||||
(define id-registry-test-pass 0)
|
||||
(define id-registry-test-fails (list))
|
||||
|
||||
(define
|
||||
id-registry-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-registry-test-count (+ id-registry-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-registry-test-pass (+ id-registry-test-pass 1))
|
||||
(append! id-registry-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idr-ev erlang-eval-ast)
|
||||
(define idrnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-session!)
|
||||
(identity-load-registry!)
|
||||
|
||||
;; ── whereis by session id ────────────────────────────────────────
|
||||
|
||||
(id-registry-test
|
||||
"registered session is found by id"
|
||||
(idrnm
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:whereis_session(Reg, s1) of\n {ok, _} -> found;\n {error, _} -> missing\n end"))
|
||||
"found")
|
||||
|
||||
(id-registry-test
|
||||
"unknown session id is not_found, not a crash"
|
||||
(idrnm
|
||||
(idr-ev
|
||||
"Reg = identity_registry:start(),\n case identity_registry:whereis_session(Reg, nope) of\n {ok, _} -> found;\n {error, Why} -> Why\n end"))
|
||||
"not_found")
|
||||
|
||||
;; ── lookup by (subject, client) — the SSO probe ──────────────────
|
||||
|
||||
(id-registry-test
|
||||
"lookup finds a session for subject+client"
|
||||
(idrnm
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:lookup(Reg, alice, web) of\n {ok, _} -> found;\n {error, _} -> missing\n end"))
|
||||
"found")
|
||||
|
||||
(id-registry-test
|
||||
"lookup is precise: right subject, wrong client misses"
|
||||
(idrnm
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:lookup(Reg, alice, cli) of\n {ok, _} -> found;\n {error, _} -> missing\n end"))
|
||||
"missing")
|
||||
|
||||
;; ── SSO fan-out: one subject, many clients ───────────────────────
|
||||
|
||||
(id-registry-test
|
||||
"sessions_for returns all of a subject's sessions"
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n identity_registry:register(Reg, s2, alice, cli, Me),\n identity_registry:register(Reg, s3, bob, web, Me),\n case identity_registry:sessions_for(Reg, alice) of\n {ok, L} -> length(L)\n end")
|
||||
2)
|
||||
|
||||
(id-registry-test
|
||||
"sessions_for an unknown subject is empty"
|
||||
(idr-ev
|
||||
"Reg = identity_registry:start(),\n case identity_registry:sessions_for(Reg, ghost) of\n {ok, L} -> length(L)\n end")
|
||||
0)
|
||||
|
||||
;; ── re-register replaces the row for that id (no duplicates) ──────
|
||||
|
||||
(id-registry-test
|
||||
"re-registering an id does not duplicate it"
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:sessions_for(Reg, alice) of\n {ok, L} -> length(L)\n end")
|
||||
1)
|
||||
|
||||
;; ── deregister removes routing ───────────────────────────────────
|
||||
|
||||
(id-registry-test
|
||||
"deregistered session is no longer found"
|
||||
(idrnm
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n identity_registry:deregister(Reg, s1),\n case identity_registry:whereis_session(Reg, s1) of\n {ok, _} -> found;\n {error, _} -> missing\n end"))
|
||||
"missing")
|
||||
|
||||
;; ── integration: route to a live session and look it up ──────────
|
||||
|
||||
(id-registry-test
|
||||
"routed-to session answers lookup as active"
|
||||
(idrnm
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_registry:register(Reg, s1, alice, web, S),\n {ok, Pid} = identity_registry:lookup(Reg, alice, web),\n case identity_session:lookup(Pid) of\n {ok, {_,_,_,St}} -> St;\n {error, St} -> St\n end"))
|
||||
"active")
|
||||
|
||||
(define
|
||||
id-registry-test-summary
|
||||
(str "registry " id-registry-test-pass "/" id-registry-test-count))
|
||||
118
lib/identity/tests/session.sx
Normal file
118
lib/identity/tests/session.sx
Normal file
@@ -0,0 +1,118 @@
|
||||
;; 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))
|
||||
81
lib/identity/tests/session_mgmt.sx
Normal file
81
lib/identity/tests/session_mgmt.sx
Normal file
@@ -0,0 +1,81 @@
|
||||
;; 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))
|
||||
115
lib/identity/tests/sso.sx
Normal file
115
lib/identity/tests/sso.sx
Normal file
@@ -0,0 +1,115 @@
|
||||
;; 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))
|
||||
215
lib/identity/tests/token.sx
Normal file
215
lib/identity/tests/token.sx
Normal file
@@ -0,0 +1,215 @@
|
||||
;; 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))
|
||||
40
lib/identity/token.sx
Normal file
40
lib/identity/token.sx
Normal file
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)
|
||||
|
||||
`bash lib/events/conformance.sh` → **376/376** (Phases 1-4 + 13 ext + tz iCal export via TZID + VTIMEZONE)
|
||||
`bash lib/events/conformance.sh` → **0/0** (not yet started)
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -54,260 +54,28 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
|
||||
```
|
||||
|
||||
## Phase 1 — Calendar + recurrence
|
||||
- [x] `calendar.sx` — event facts, RRULE expansion in a window (DAILY/WEEKLY)
|
||||
- [x] `calendar.sx` — MONTHLY (bymonthday + nth-weekday byday)
|
||||
- [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)
|
||||
- [ ] `calendar.sx` — event facts, RRULE expansion in a window
|
||||
- [ ] `availability.sx` — free/busy rules
|
||||
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
||||
|
||||
## Phase 2 — Ticketing + booking
|
||||
- [x] capacity rules; transactional booking → `persist` (no overbooking)
|
||||
- [x] wire `booking.sx` into `api.sx` (persist-backed `ev/book-occ!` + derived availability)
|
||||
- [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
|
||||
- [ ] capacity rules; transactional booking → `persist` (no overbooking)
|
||||
- [ ] paid tickets compose with `commerce` order flow
|
||||
- [ ] tests: capacity edge, double-book guard, conflict detection
|
||||
|
||||
## Phase 3 — Notification delivery (flow)
|
||||
- [x] `notify.sx` — reminder/digest flows over injected transport
|
||||
- [x] retry/backoff on transport failure (flow suspend/resume)
|
||||
- [x] tests: delivery success, retry path, idempotent re-send
|
||||
- [x] wire reminders to occurrences (`reminders.sx` — derive from agenda + roster)
|
||||
- [x] end-to-end pipeline: derive (reminders/booking/reschedule) → deliver via
|
||||
the notify flow (`ev/deliver-messages`, SX→Scheme bridge)
|
||||
- [ ] `notify.sx` — reminder/digest flows over injected transport
|
||||
- [ ] retry/backoff on transport failure (flow suspend/resume)
|
||||
- [ ] tests: delivery success, retry path, idempotent re-send
|
||||
- [ ] NOTE: shared with `feed/notify` — candidate for later extraction to a
|
||||
`delivery-on-sx` once a second consumer is real. **Delivery core
|
||||
(request→dispatch→resume, idempotent, bounded retry) is the extraction seam.**
|
||||
`delivery-on-sx` once a second consumer is real
|
||||
|
||||
## Phase 4 — Federation
|
||||
- [x] cross-instance events (peer calendar) — trust-gated stub
|
||||
- [x] tests: federated agenda merge
|
||||
- [x] federated availability/free-busy across trusted peers
|
||||
- [x] injected transport (`ev/federated-agenda-via` + fetch) — fed-sx-ready, graceful degradation
|
||||
- [ ] cross-instance events (peer calendar) — trust-gated stub
|
||||
- [ ] tests: federated agenda merge
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-06-07 — VTIMEZONE iCal export (supersedes the UTC-Z tz fix — full DST
|
||||
fidelity). A tz event now exports DTSTART;TZID=<name>:<local> (+ EXDATE/RDATE
|
||||
in the same TZID-local form; UNTIL stays UTC per RFC), and the VCALENDAR emits
|
||||
a VTIMEZONE per distinct zone with DAYLIGHT/STANDARD sub-components generated
|
||||
from the zone's transition rules (offsets + FREQ=YEARLY;BYMONTH;BYDAY) — the
|
||||
London/Paris blocks match real-world definitions exactly. So a client recurs
|
||||
the event at a fixed WALL-CLOCK time, DST-correct (the prior caveat is gone).
|
||||
`ev-ical-vtimezone`, `ev-ical-offset`, distinct-zone collection; importer now
|
||||
tolerates the ;TZID= parameter. +16 tests (ical 56), 376/376 green.
|
||||
- 2026-06-07 — Fix: timezone-aware iCal export. Bug — tz events store wall-clock
|
||||
LOCAL times, but export stamped them with a `Z` (UTC) suffix, so a London
|
||||
18:00 event falsely read as 18:00 UTC. `ev-ical-conv` now converts a tz
|
||||
event's DTSTART / UNTIL / EXDATE / RDATE local→UTC before formatting (London
|
||||
summer 18:00 → 170000Z; Paris → 160000Z); non-tz events unchanged. Documented
|
||||
caveat: a UTC RRULE drifts from a wall-clock-stable tz recurrence across a DST
|
||||
boundary — full fidelity needs VTIMEZONE (deferred). +6 tests, 366/366 green.
|
||||
- 2026-06-07 — iCalendar import / round-trip (extension). `ical.sx` now parses
|
||||
VEVENT/VCALENDAR text back into events (`ev/ical-lines->event`,
|
||||
`ev/parse-vcalendar`): DTSTART/DURATION/RRULE (incl. ordinal BYDAY, BYMONTHDAY,
|
||||
UNTIL/COUNT/INTERVAL) and EXDATE/RDATE. Round-trip is occurrence-exact —
|
||||
export→import expands to the identical occurrence set (tested across one-off /
|
||||
daily-count / weekly+exdate+rdate / monthly-ordinal / bymonthday). Completes
|
||||
bidirectional interop. +19 tests, 360/360 green.
|
||||
- 2026-06-07 — Whole-series booking (extension). `ev/book-series!` /
|
||||
`ev/cancel-series!` apply a booking/cancel to every occurrence of one event
|
||||
in a window (e.g. RSVP the whole weekly class), returning per-occurrence
|
||||
(occ-key status) results; capacity is still enforced per occurrence (some
|
||||
:booked, some :full). Idempotent re-book (all :already). `ev/series-count`
|
||||
(tally a status), `ev/series-booked` (which occurrences the actor holds).
|
||||
+9 tests, 341/341 green. This was the last flagged feature — surface saturated.
|
||||
- 2026-06-07 — iCalendar (RFC 5545) export (extension). `ical.sx` serializes
|
||||
events to VEVENT / VCALENDAR text for import by standard clients. UTC
|
||||
basic-format stamps (YYYYMMDDTHHMM00Z), DURATION (PT#H#M), and the full RRULE
|
||||
model (FREQ/INTERVAL/COUNT/UNTIL/BYDAY incl. monthly ordinals "2TU"/"-1FR"/
|
||||
BYMONTHDAY) plus EXDATE/RDATE. Line-oriented: `ev/event->ical-lines` /
|
||||
`ev/events->ical-lines` return content lines; `ev/ical-render` joins with
|
||||
CRLF (wire format). +21 tests, 332/332 green.
|
||||
- 2026-06-07 — Cross-event conflict-checked booking (extension). Capacity is
|
||||
per-event, but `ev/book-checked!` also prevents an attendee double-booking
|
||||
THEMSELVES across different events: it consults the actor's persist-derived
|
||||
availability (ev/free-p?) for the occurrence's window and returns
|
||||
:time-conflict on overlap, else the normal ev/book-occ! result. Re-booking
|
||||
the same occurrence is idempotent (:already, not a conflict); other actors are
|
||||
unaffected. `ev/would-time-conflict?` predicate. +8 tests, 311/311 green.
|
||||
- 2026-06-07 — End-to-end delivery pipeline (closes the derivation↔delivery
|
||||
gap). `ev/deliver-messages` bridges SX notification messages to the Scheme
|
||||
notify flow: each (id recipient body) is `serialize`d to s-expression text,
|
||||
spliced as quoted data into the digest-flow program, delivered over an
|
||||
injected transport-src, and results unboxed ({:scm-string}→str). New
|
||||
integration suite drives all three derivations through delivery: reminders →
|
||||
delivered (ids = idempotency keys), transient-fail transport → failed,
|
||||
waitlist-promotion notification → delivered, reschedule notice → delivered,
|
||||
empty batch → empty (guarded: an empty digest completes without suspending).
|
||||
+8 tests, 303/303 green.
|
||||
- 2026-06-07 — Timezone + DST support (user request). `timezone.sx`: a tz maps
|
||||
wall-clock LOCAL ↔ absolute UTC (offset = local-utc). :fixed (constant) and
|
||||
:dst (std/dst offsets + two UTC transition rules, e.g. EU last-Sun-Mar/Oct
|
||||
01:00 UTC) zones, no IANA DB — transitions computed via calendar helpers
|
||||
(ev-resolve-nth-weekday). `ev-event-tz` authors an event in local time + a tz;
|
||||
`ev-expand` dispatches: tz events expand in LOCAL time (recurrence + EXDATE/
|
||||
RDATE + overrides all wall-clock), then each occurrence converts to UTC, so a
|
||||
"09:00 weekly" meeting stays 09:00 across a DST change (its UTC instant
|
||||
shifts). Predefined ev-tz-utc/london/paris. local->utc inverts with a one-step
|
||||
refinement. Plain events unaffected (ev-expand-naive). +17 tests, 295/295 green.
|
||||
- 2026-06-07 — Injected federation transport (last plan item). `fetch` abstracts
|
||||
how a peer's agenda arrives: (fetch peer-id ws we) -> {:status :ok :occurrences}
|
||||
| {:status :error}. `ev/federated-agenda-via` merges local + each trusted
|
||||
peer fetched via the transport, tagged with :origin; an unreachable peer is
|
||||
skipped (graceful degradation), never breaking the agenda.
|
||||
`ev/peer-fetch` is the in-process adapter (runs the existing store model
|
||||
through the same interface); a real fed-sx/signed-fetch transport drops in
|
||||
unchanged. `ev/federation-status` reports per-peer reachability. +6 tests,
|
||||
278/278 green. All plan checkboxes (incl. extensions) now ticked.
|
||||
- 2026-06-07 — Reschedule notifications (extension). When an event carries
|
||||
per-occurrence overrides, `ev/reschedule-notifications` reads the roster at
|
||||
each overridden occurrence's ORIGINAL occ-key and produces a reschedule
|
||||
message per booked attendee (old-start, new-start, new-duration). Idempotency
|
||||
key = original-key/reschedule/new-start. `ev/reschedule-notify->msg` for the
|
||||
notify wire shape. Combines overrides (calendar) + rosters (booking) + the
|
||||
message-derivation pattern. +7 tests, 272/272 green.
|
||||
- 2026-06-07 — Booking lifecycle notifications (extension). `booking-notify.sx`
|
||||
walks the booking stream into ordered notifications classified by kind:
|
||||
:booked / :promoted / :held / :confirmed / :released / :cancelled /
|
||||
:waitlisted. Promotion is detected by folding the waitlist as we walk (a
|
||||
:booking for a currently-waitlisted actor is a promotion, not a fresh
|
||||
booking). id = occ-key/seq (stable stream seq → idempotent re-derivation, no
|
||||
double-ping). `ev/booking-notifications`, `ev/notify-of-kind`,
|
||||
`ev/booking-notify->msg` (notify wire shape). Connects ticketing to the
|
||||
delivery layer. +11 tests, 265/265 green.
|
||||
- 2026-06-07 — Per-occurrence overrides / reschedule (RFC 5545 RECURRENCE-ID).
|
||||
`ev-with-override event orig-start new-start new-duration` adds an :overrides
|
||||
entry keyed by the occurrence's original start. `ev-expand` applies overrides
|
||||
after EXDATE/RDATE: a targeted instance is re-timed/re-sized and the agenda
|
||||
re-sorted; an instance moved out of the window is dropped (slot vacated);
|
||||
override of a non-occurring start is a no-op. Used `assoc` for immutable
|
||||
event update. +6 tests, 254/254 green.
|
||||
- 2026-06-07 — RRULE exceptions EXDATE/RDATE (extension). `ev-event-full`
|
||||
carries :exdate/:rdate (epoch-minute starts). Raw expansion renamed
|
||||
`ev-expand-base`; `ev-expand` now applies exceptions: RDATE adds explicit
|
||||
in-window occurrences, EXDATE removes matching starts, duplicates de-duped,
|
||||
EXDATE wins over RDATE and the rrule (RFC 5545). RDATE-only events (no rrule)
|
||||
supported. Plain `ev-event` (no exception keys) unaffected. +8 tests,
|
||||
248/248 green.
|
||||
- 2026-06-07 — Waitlist + auto-promotion (extension). When an occurrence is
|
||||
full, `ev/waitlist!` queues actors FIFO (:waitlist/:unwaitlist events on the
|
||||
same stream; waiting fold is independent of the seat fold since taking a seat
|
||||
removes from the queue). `ev/waitlist` (queue), `ev/waitlist-position`,
|
||||
`ev/leave-waitlist!`. `ev/cancel-promote!` cancels a seat and auto-promotes
|
||||
the head of the queue to a confirmed booking when capacity opens. Idempotent
|
||||
(:already / :already-waiting). +21 tests, 240/240 green.
|
||||
- 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.
|
||||
(loop fills this in)
|
||||
|
||||
## Blockers
|
||||
|
||||
- None. Substrates present: `lib/datalog` (276/276), `lib/persist`, `lib/flow`
|
||||
all exist — Phase 2/3 unblocked when reached.
|
||||
(loop fills this in)
|
||||
|
||||
@@ -19,7 +19,7 @@ through the event log, all authorization questions delegated to `acl-on-sx`.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/identity/conformance.sh` → **0/0** (not yet started)
|
||||
`bash lib/identity/conformance.sh` → **233/233** (4 phases + 15 ext) — slow (~10min, run in background; internal timeout 1200)
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -57,28 +57,237 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke)
|
||||
```
|
||||
|
||||
## Phase 1 — Sessions + tokens
|
||||
- [ ] `session.sx` — session process, create/lookup/expire
|
||||
- [ ] `token.sx` — issue/introspect/revoke (opaque, grant-backed)
|
||||
- [ ] `registry.sx` — route by subject/client
|
||||
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
||||
- [x] `session.sx` — session process, create/lookup/expire
|
||||
- [x] `token.sx` — issue/introspect/revoke (opaque, grant-backed)
|
||||
- [x] `registry.sx` — route by subject/client
|
||||
- [x] `api.sx` + tests + scoreboard + conformance.sh
|
||||
|
||||
## Phase 2 — OAuth2 flows
|
||||
- [ ] authorization-code flow as a message protocol
|
||||
- [ ] refresh + rotation; revocation cascades to issued tokens
|
||||
- [ ] tests: full code exchange, refresh, revoke-then-use (must fail)
|
||||
- [x] authorization-code flow as a message protocol
|
||||
- [x] refresh + rotation; revocation cascades to issued tokens
|
||||
- [x] tests: full code exchange, refresh, revoke-then-use (must fail)
|
||||
|
||||
## Phase 3 — Silent SSO + membership
|
||||
- [ ] `prompt=none` cross-app login (one session, many clients)
|
||||
- [ ] membership state + per-app grant projection
|
||||
- [ ] grant verification delegated cache (mirror Redis-cache pattern)
|
||||
- [x] `prompt=none` cross-app login (one session, many clients)
|
||||
- [x] membership state + per-app grant projection
|
||||
- [x] grant verification delegated cache (mirror Redis-cache pattern)
|
||||
|
||||
## Phase 4 — Audit + federation
|
||||
- [ ] every issue/refresh/revoke is a `persist` event; `(identity/audit subject)`
|
||||
- [ ] federated identity (peer-asserted subject) — advisory, trust-gated stub
|
||||
- [ ] tests: audit completeness, cross-instance subject mapping
|
||||
- [x] every issue/refresh/revoke is a `persist` event; `(identity/audit subject)`
|
||||
- [x] federated identity (peer-asserted subject) — advisory, trust-gated stub
|
||||
- [x] 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] "disconnect app": `revoke_app(Subject, Client)` — revoke all of a subject's grants for a client
|
||||
- [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
|
||||
(loop fills this in)
|
||||
- 2026-06-07 — "disconnect app" (ext): `identity_tokens:revoke_app(Subject,
|
||||
Client)` revokes every grant a subject holds for one client at once (audited
|
||||
one revoke per grant), exposed at the facade as `identity:revoke_app`. The
|
||||
action counterpart to the `grants` view — completes the account-security
|
||||
view+action pairs: sessions/logout_all, grants/revoke_app, history. Other
|
||||
subjects' same-client grants are untouched. +4 → account 11, 233/233.
|
||||
- 2026-06-07 — "apps with access" (ext): `identity_tokens:grants_for(Subject)`
|
||||
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
|
||||
(loop fills this in)
|
||||
- 2026-06-07 — **PKCE S256 blocked: erlang binary bugs.** Two substrate bugs
|
||||
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