-
v0.5.4
Stablereleased this
2026-09-29 19:11:13 +00:00 | 250 commits to main since this release- Accounts the content mandates, made at start (
src/seed.rs). Every person a page names asresponsiblegets a Kanidm account if they have none, and a mail in the site's language saying they are listed as responsible for asking that question, with the link to sign in;RESPONSIBLE_GROUPputs them all in one group (people who already had an account are added without a mail).SEED_ADMIN_EMAIL(andSEED_ADMIN_NAME,SEED_GROUP) invites the first person into the site's most privileged group - the group whose desk may invite into the most groups - so a site with its own Kanidm has someone to invite everyone else. Both happen once per address and group (portal_seed), side by side, and each tries again a minute apart, twenty times, while Kanidm comes up. The responsible are gone over again whenever the content changes (portal.content.reloaded, published once new content is in place), so a new page's person is set up without a restart - and someone the content has stopped naming leavesRESPONSIBLE_GROUPin the same pass, if portal is who put them there. - An invite is a case that keeps itself current (
portal_invites:open,expired,done). Signing in closes a person's invites; a sweep every ten minutes moves one whose link ran out unused toexpired, and takes the link off the record. Fromexpireda desk may send it again: the move back toopenmakes a fresh link and mails it. Content offers that with{ from: expired, to: open }on the list of invites. The invites portal makes itself at start are on file with the rest. - Fixed: an invite mail said the link worked "until" and then nothing. Kanidm 1.11 gives a link's end as seconds since the epoch, and portal read only the older RFC 3339 string.
- Fixed: a search by mail that Kanidm failed to answer (a 5xx) was read as "not permitted", and the invite went on to match by username - which could give someone with an account under another name a second one. Only a refusal is read that way now.
- A page's responsible person, signed in, may suggest a change to it: a card at the foot of their own pages, checked on the server against the page's
responsible.contact, published onportal.edits.suggested, and mailed to the site's inbox with them as reply-to. It is a case in the built-inportal_edit_suggestionsbucket -open,discussed,doneordeclined- so a desk that lists the bucket takes it up and settles it, the person who made it is mailed at each move, and the card shows them what became of what they sent before. The session now carries the signed-in person's email. - The mail to someone named responsible names the first three questions and counts the rest; on a site where one address answers for every page it used to list them all.
- Fixed: an invite of someone new failed on a Kanidm set up with the onboarding account alone. Portal created the person, then set their address in a second call, and
idm_people_on_boardingmay create but not modify: 403. The address now goes in with the person.
Downloads
- Accounts the content mandates, made at start (
-
v0.5.3
Stablereleased this
2026-09-29 11:30:22 +00:00 | 258 commits to main since this release- A way down the tree from every page: an arrow fixed at the left edge, half-way up, and the same link in words at the foot ("Tilbake til starten", "Back to “What came in?”"). It goes to the nearest page below the current one that the visitor may open, taking the path apart a segment at a time, and to the front door when none is; the front door has none. Content no longer needs a "back" alternative, and a visitor who went up a branch that turned out not to apply is never stranded.
Downloads
-
v0.5.2
Stablereleased this
2026-09-28 16:09:28 +00:00 | 263 commits to main since this release- The rate limiter answered 500 to any request that arrived without
X-Forwarded-For. It reads the caller from that header orX-Real-IPand falls back to the socket, and 0.5.0 served the app without connect info, so there was no socket to fall back to and no key to rate-limit by. Through Caddy every request carries the header and the site was fine; a health check on localhost got a 500, which is how the first deploy of 0.5.1 failed.
Downloads
- The rate limiter answered 500 to any request that arrived without
-
v0.5.1
Stablereleased this
2026-09-28 15:48:37 +00:00 | 264 commits to main since this release- A
self_transitionthat names nofrommeans the bucket's own initial state, not the wordopen. 0.5.0 read the literal, which is only right for a bucket whose records start there: redoal's watchers arrive atpendingand confirm themselves intoopen, so the rule compiled asopen -> openand the confirm link in the mail could never match. Wrong the same way before 0.5.0, and now visible because the lint checks the edge exists.
Downloads
- A
-
v0.5.0
Stablereleased this
2026-09-28 14:46:50 +00:00 | 265 commits to main since this release- Sessions live in NATS KV (
src/sessions.rs), not in memory. Every restart used to sign out every desk on the site being deployed; they now survive it, in the JetStream that is already under everything else - no new service and no new store to run. The bucket'smax_agecollects what nobody returns for, and a record's own expiry is what answers a load. - Fixed: the JetStream integration test asserted a record's projected state was one a desk move had never written to it, so it failed whenever it actually ran.
test.ymlhas been red since 0.3.47 whilepublish.ymlstayed green, which is how 0.4.0 shipped past it. - A file can come back out.
GET /attachment/{bucket}/{record}/{field}streams what atype: filefield stored, reading the object key out of the record rather than taking one from the caller - so a file is only reachable through a record somebody may see, and a key that leaks off a desk is a link to nothing. Before this, uploads were one-way: a desk showed the key as a string and there was no way to open it. - Rate limiting, per caller IP, outside everything else: a burst of 30 refilled one every two seconds (
PORTAL_RATE_BURST,PORTAL_RATE_SECONDS; either 0 turns it off). A form-filling visitor never reaches it; a script does. There was none at all before. /uploadasks the policy, like every other way in. It asked nothing: any caller who could reach the host could put 20 MB in the bucket, as often as they liked, and a site whose forms are public is a site whose upload route is public. It now takes thequestionandalternativethe file is being attached to, refuses a page that declares notype: filefield or an alternative that is closed, and asksSubmiton that page - the same question the submission itself will ask - before a byte is written. Latent until an instance had storage configured; the first one now does.- A submitter stays in dialogue with their case for as long as it runs.
self_transitiontakesfrom: [state, ...], and the access policy compiles one rule per state named; it defaulted to the initial state alone and was written that way in the compiler, so an applicant written to for a missing drawing could not send it, and nobody could withdraw once anything had happened. Omitted, it still means the initial state alone. - A record can belong to another record.
type: recordwithof: <bucket>stores the parent's id, which arrives on the link rather than being typed; a submission naming a record that is not there is refused. The other half iswhere: { field, equals }on a Kv resource, so an application's page can list the objections that name it. - A public view need not publish the whole bucket.
states: [...]on a Kv resource shows only those, for one record as well as a listing - a register that is public by law is public about what was decided, not about what was withdrawn. The desk over the same bucket is a different spec and still sees all of it. - A state can grant a group:
grants: <group>beside itsevent, so an approval that means membership does not also need somebody to remember to invite them afterwards. The lint requires the forms that fill such a bucket to collect a required name and email, because by the time a desk presses approve there is nobody left to ask. - A state can be moved by a clock:
after: { days, to }, swept hourly, measured from the record's last move (src/deadlines.rs). A deadline may only do what a person could have done -after.tois held to the same declared edge as a desk button - and the move fires the same mail and publishes the same event, stampeddeadline. - Documentation moved out of the README into
docs/: architecture, content reference, state machines, access, operations. - A page says nothing until it has something to say. The Suspense fallback on a question was the whole served page - no header, no wordmark - so the first and only thing a visitor or a crawler read was the word "loading…", which is true and is not about them. It now holds the space and stays quiet, with
aria-busyfor a reader that would otherwise find an empty document. - Gone: the
gitea_org_reposresource kind, which no site ever named.gitea_starredandgitea_releasesstay.
Downloads
- Sessions live in NATS KV (
-
v0.4.0
Stablereleased this
2026-09-24 19:26:57 +00:00 | 271 commits to main since this release- A field can be a place:
type: locationputs a pin on a map and stores{lat, lon, zoom}; a record holding one renders as a still map on its desk and in any resource card. - A question can be about a place:
place: { lat, lon, zoom, name }is the counterpart toevent:- event makes a page an occasion with a time, place makes it somewhere, draws the map under the question, and opens any location field on that page there instead of on the globe. - Maps are served from this origin. Portal fetches every tile and every still itself (
src/maps.rs, MapLibre vendored), so a page with a map on it makes no third-party request, needs no consent banner, and theMAPBOX_TOKENnever leaves the server. Unset, the feature is simply off. - A reply to a case mail becomes a note on its record: whatever reads the mailbox publishes on
portal.mail.receivedand portal appends the note, after checking the record exists and the sender is the address the record itself holds. A note never moves a case.
Downloads
- A field can be a place:
-
v0.3.47
Stablereleased this
2026-09-24 03:17:45 +00:00 | 273 commits to main since this release- iris: needs.yaml takes
stage_words, each state in the site's own words, carried into the simulation model for the trainer's desk score. No change to portal itself.
Downloads
- iris: needs.yaml takes
-
v0.3.46
Stablereleased this
2026-09-23 20:18:28 +00:00 | 274 commits to main since this release- An invite first looks the email up in Kanidm (
/v1/raw/searchonmail, when the service account may read people:idm_people_pii_read) and only adds an existing account, under whatever username, to the group; no second account, no reset link.
Downloads
- An invite first looks the email up in Kanidm (
-
v0.3.45
Stablereleased this
2026-09-23 19:24:09 +00:00 | 275 commits to main since this release- A mail stream that cannot be created at boot (subjects owned by an older stream) is a warning, not a failed start; 0.3.44 took the kasse sites down for two minutes on the rename.
Downloads
-
v0.3.44
Stablereleased this
2026-09-23 19:15:40 +00:00 | 276 commits to main since this release- The mail stream is named
mail(wasWORMHOLE); gdo 0.1.2 matches. Delete the old stream on each NATS after upgrading both.
Downloads
- The mail stream is named