Changelog
One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a v<version> tag; the
content repos pin the tag they run (see the end of this file).
0.6.0 (2026-09-30)
- One binary. The lint (uhhm/iris) and the trainer (uhhm/portal-trainer) are commands of
portal:portal serve(the default),portal lint, andportal plan,build,answer,rate,prefer,score,simulate,compare,report,generate. Same source, same version, so the site, the lint and the trainer never disagree about what content means; both histories are merged in. The release attaches the bareportalbinary beside the tarball, so a content repo’s CI downloads it and runsportal lint. No more iris releases; the pins that exist keep working. The trainer’splans/,intakes/,engines/,sectors/and its reference prompt live here now; itsruns/stay in uhhm/portal-trainer. Docs gained “Training a site” and “The lint”.
0.5.11 (2026-09-30, tagged, never published)
The publish job failed at its lint step: iris, built against this tag, did not compile against the storylines change (the same two fixes that went into src/lint/ when the lint moved in). Nothing was attached to the tag; 0.6.0 is the first release that ships storylines.
- Storylines (
src/content/variants.rs,docs/storylines.md): any text a person reads may be a list, one entry per storyline, every list the same length; a visitor is given one storyline for the whole site (theportal_storylinecookie, a year) and reads it on every page and in every mail about their case. What the machine reads - a field’sname,options, states, groups, paths - never varies. The lint refuses a list of another length. Records and answer events carrystoryline, andGET /reportgives counts per bucket, per storyline, per state, and nothing else.
0.5.10 (2026-09-30)
- The lint lets an invite form collect a required
phonein place of a requiredemail: memberships know a person by number first.
0.5.9 (2026-09-30)
- With memberships as the people backend and a Kanidm token still set, an invite makes the Kanidm account as well (no group, the one-time link in the mail): Kanidm makes people, portal makes members, and a site can switch to memberships before its sign-in provider is one everyone already has.
0.5.8 (2026-09-30)
- Fixed: 0.5.7 asked every provider for the
phoneNumberscope, and Kanidm denies a sign-in that asks for a scope the person does not hold, so nobody could sign in. The scope is asked for only withOIDC_EXTRA_SCOPES=phoneNumber(Vipps). 0.5.7 must not be deployed on a Kanidm site.
0.5.7 (2026-09-30)
- Memberships: groups kept by portal itself (
src/memberships.rs,docs/memberships.md). A membership is a case in the built-inportal_membershipsbucket -invitedby a desk, agrants, the seed or the responsible pass;activefrom the person’s first sign-in, when the provider’s subject, phone number or address matches;removedby a desk. A person is their phone number first (+4791234567,PHONE_COUNTRYfor bare numbers), their address second, the provider’s subject third, and one record per group and person however it was keyed. At sign-in the active memberships join thegroupsclaim, so Cedar sees the same parents as before.PEOPLE_BACKEND=membershipsswitches a site; unset, aKANIDM_API_TOKENmeans Kanidm and no token means memberships.SEED_ADMIN_PHONEseeds by number. The sign-in asks for thephoneNumberscope and readsphone_numberfrom userinfo. - The NATS access service answers for a person named by
sub,phoneoremailwith no groups listed: their memberships are looked up first. who_for(the person agrantsis about) takesnavnas well asname, and a phone number in place of an address.- The invite mail for someone who needs no link says where to sign in.
0.5.6 (2026-09-30)
- A group may hold a page’s responsibility:
responsible: { name, contact, group: styret }. No account or mail for the inbox, and any signed-in member of the group gets the suggest-a-change card. Before this, a board’s shared address was onboarded as a person.
0.5.5 (2026-09-30)
- One record answers another, from a list.
linkon a resource puts a link on every listed record to a page about it ({id},{field}), a state mail’stomay be a field of the record this one belongs to ({notice.email}, and{notice.field}in the text), andvalue: "{user.email}"fills a field from the session and hides it, so a signed-in person is not asked who they are. Together they make a board: a notice is listed with “answer this”, the answer page’s form carries the notice’s id, and the mail goes to whoever posted it. The lint requires the record field the mail reaches through. - The way down the tree is the line at the foot of the page and nothing else. The arrow fixed at the left edge, which 0.5.3 added beside it, is gone: it followed the reader down every page to offer something the foot already does.
0.5.4 (2026-09-29)
- 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.
0.5.3 (2026-09-29)
- 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.
0.5.2 (2026-09-28)
- 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.
0.5.1 (2026-09-28)
- 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.
0.5.0 (2026-09-28)
- 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.
0.4.0 (2026-09-24)
- 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.
0.3.47 (2026-09-24)
- 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.
0.3.46 (2026-09-23)
- 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.
0.3.45 (2026-09-23)
- 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.
0.3.44 (2026-09-23)
- The mail stream is named
mail(wasWORMHOLE); gdo 0.1.2 matches. Delete the old stream on each NATS after upgrading both.
0.3.43 (2026-09-23)
- Mail is content:
mail: { to, subject, body }on a state in aggregates.yaml sends when a record enters that state (to: submitteror one address;{field},{site},{chain},{email}in the templates), and an invite mails its one-time link built in;site.yamlgainsmail: { from, reply_to, invite }. Portal renders and publishes onportal.mail.sendin theWORMHOLEstream; uhhm/gdo delivers. The lint requires a requiredemailfield on every form recording into a bucket that mails the submitter.
0.3.42 (2026-09-23)
- people: the Kanidm person lookup reads the body, since Kanidm 1.11 answers a missing person with 200 and
nullrather than 404; before this every invite skipped account creation and failed on the group step
0.3.41 (2026-09-23)
- publish: the iris release step retries on a fresh clone when its push loses a race against a push to iris main (the v0.3.40 tag never published)
- iris:
check --accessprints the access matrix, and every check validates the compiled policy against the schema
0.3.40 (2026-09-23)
- One access policy, compiled from content into Cedar on every load (
src/access.rs): pages, resource reads, desk transitions, self transitions and the automation endpoint all decide through it; decisions carry rule ids, refusals and mutations publish onportal.access.decided, andportal.access.<site>.mayanswers the same question for other apps over NATS invite: {group}on an alternative onboards a person into Kanidm from a desk (src/people.rs,KANIDM_API_TOKEN), recording the one-time credential link inportal_invitesand onportal.people.invited
0.3.39 (2026-09-23)
- publish: releases the matching uhhm/iris by proxy - re-pins, builds, tests and tags it as this version (needs the
IRIS_PUSH_USERvariable andIRIS_PUSH_TOKENsecret); iris carries portal’s version number from here on
0.3.38 (2026-09-22)
- publish: the iris build step gets a writable cargo home; the v0.3.37 tag never published
0.3.37 (2026-09-22)
- The lint moves to its own repo, uhhm/iris:
question_lintbecomesiris check,needs_replaybecomesiris replay; the tarball shipsiris(pinned in publish.yml) instead ofquestion_lint; the needs module and local loaders leave portal
0.3.36 (2026-09-22)
- Question nav: a followup is offered only once earned - the verified chain answered the alternative leading there, or a link that records nothing leads on from an earned page. Standing on a page no longer unlocks the pages behind its forms; the chain index now records which alternative was answered
- One bounded HTTP client for every server-side fetch: 20 s budget, size caps per kind of fetch (
src/http.rs) - Uploads are refused past 20 MB while streaming, and the route body limit that had capped them at axum’s 2 MB default is raised to match
tests/jetstream.rs: the store, the event log and the projection tested against a real JetStream (CI starts a throwaway one)contentsplit intogitea,validate,markdown,handlers; every path stillcontent::x- This changelog, and the table of what each site runs
- Business needs as a checkable spec: needs.yaml + question_lint pass
- needs:
successstates and a resolved simulation model - needs:
stages- every stage the business named must be a state - needs_replay: make simulated cases real aggregates, report buckets by state
- needs: also_moved_by - a need carried by more than one group’s desk
0.3.35 (2026-09-12)
- Item cards: no paragraph margin under the description
0.3.34 (2026-09-11)
- Question nav: offer followups by reach, not by carrying any chain
0.3.33 (2026-09-02)
- Head preloads: latin font, wordmark, content-host preconnect
0.3.32 (2026-09-01)
- Per-site favicon via site.yaml
0.3.31 (2026-09-01)
- Markdown links allow tel:
0.3.30 (2026-09-01)
- Single alt-image shows natural height
0.3.29 (2026-09-01)
- Markdown image layout hints + AVIF assets
0.3.28 (2026-09-01)
- Hero description renders inline markdown
0.3.27 (2026-09-01)
- wordmark_invert: explicit, not inferred from .svg
0.3.26 (2026-09-01)
- Select fields: inline static options
0.3.25 (2026-09-01)
- Content-shipped stylesheet override (site.yaml stylesheet)
0.3.24 (2026-09-01)
- Gateway submit-as-link loses the underline
0.3.23 (2026-09-01)
- Chrome speaks the site’s language: site.yaml lang + i18n table
0.3.22 (2026-09-01)
- site.yaml wordmark_height; raster logos keep their colors
0.3.21 (2026-09-01)
- Hero paragraph centers its measure box
0.3.20 (2026-09-01)
- Sign-in becomes optional: KANIDM_URL unset disables auth cleanly
0.3.19 (2026-09-01)
- Markdown images in descriptions, gated and framed
0.3.18 (2026-09-01)
- Item cards render inline markdown descriptions
- Convergence: ink converges onto the decoded key (WebGL glow)
- Gesture results overlay the canvas - the page never jumps
0.3.16 (2026-08-30)
- Announce sweeper: refresh open records from content
0.3.15 (2026-08-30)
- Hidden fields: carrier value without a label row
0.3.14 (2026-08-30)
- Feature cards: icon column, one text edge
0.3.13 (2026-08-30)
- Voice field: nothing leaves the browser until Keep it here
0.3.12 (2026-08-30)
- Voice preview stays hidden until there is something to play
0.3.11 (2026-08-30)
- README: gesture and voice fields
- Validate templated actions against the page pattern
0.3.10 (2026-08-30)
- type: voice, Requirement.value, playable resource cards
0.3.9 (2026-08-30)
- Templated actions; places are pickable in the gesture input
0.3.8 (2026-08-30)
- Descriptions are inline markdown; Feature.link withdrawn
0.3.7 (2026-08-30)
- Feature.link: a card’s name may point elsewhere
0.3.6 (2026-08-30)
- Announced pages: event windows, header announcements, summary tasks
0.3.5 (2026-08-30)
- Gesture widget: stop drawing the decoded ghost path
- Gesture widget: ghost path back, for v2 keys only
0.3.4 (2026-08-25)
- Alternative.disabled: announced but not yet takeable
0.3.3 (2026-08-25)
- Empty list says so before it can parse as zero answers
0.3.2 (2026-08-25)
- README: hashed pkg assets in the release flow
- Resource empty text: content-declared, and null counts as empty
0.3.1 (2026-08-25)
- Content-hashed pkg assets (hash-files) so stale bundles can’t pair with new wasm
0.3.0 (2026-08-25)
- Hero becomes a content-owned module; site asset proxy; gesture growth fix
0.2.4 (2026-08-25)
- Gates keep the question nav
0.2.3 (2026-08-25)
- Attended-bucket lint rule; app-lifetime resources fix first-nav corruption
0.2.2 (2026-08-24)
- One app-level Title fed by a shared site resource
0.2.1 (2026-08-24)
- Docs: routing bullet + design doc marked implemented
- Revalidating cache on app assets; site title survives SPA navigation
0.2.0 (2026-08-24)
- Initial commit: content-driven onboarding portal
- Load content from Gitea directly, drop the local clone; add deploy workflow
- Share sccache between manual dev builds and CI
- Give CI its own SCCACHE_SERVER_PORT
- Erase view types at every list/component boundary, not just leaves
- Redirect cargo-leptos’s tool cache out of act-runner-bare’s StateDirectory
- Fix Ship release: site-root is project-relative, not CARGO_TARGET_DIR
- Drop sudo from the deploy workflow - NoNewPrivileges blocks it outright
- Caddy snippet: use multi-line log block
- Fix static asset 404s and build-time SITE_NAME
- Hot-reload content on a NATS trigger instead of requiring a restart
- Add prosekit rich-text field, Gitea repo embeds, automation KV read endpoint; fix apex/www session-cookie mismatch on /auth/callback
- Fix prosekit-editor.js: remove bare CSS imports that aborted the whole module
- Redirect back to the originating page after sign-in, not always /
- Fix hero-canvas navigation race; style the prosekit editor and add a toolbar
- yes.js: guard against the canvas not being in the DOM yet
- Fix get_resource/transition_answer: scope feature lookup to its own alternative
- Add self-service transitions, authorized by item possession not group membership
- Event-sourced applicant/subscriber/project aggregates, generalized resources
- deploy.yml: write GITEA_API_TOKEN for the new GiteaStarred/OrgRepos resource sources
- Cargo.toml: disambiguate bin-target for cargo-leptos
- Add manually-triggered backfill workflow, no terminal/sudo needed
- Trigger redeploy to pick up PORTAL_GITEA_API_TOKEN secret
- Render generic resource lists as cards, not raw JSON dump
- Fix recursion-limit build failure in the new item-card renderer
- Resource-backed multi/single-select requirement
- Add Organization aggregate (client-as-status), wired but inert
- Fix favicon (real icon, not an unrelated orange circle) + stack encouragements with the submit button
- Fix bucket-404 on empty resources, redesign review actions as select-then-confirm, CSS polish
- Inline encouragements with the submit button, vertically centered
- Use the real institutional logo for the header wordmark, not plain text
- Use the actual UHHM-letters wordmark, not the institutional mark
- Responsive base font size via svmin, not fixed 17px
- Add plain-unit fallbacks for svmin/svh in case of unsupported engines
- Size the YES canvas from its container, not window.innerHeight
- Debounce the YES canvas resize handler and skip no-op resizes
- Revert canvas sizing to window.innerWidth/innerHeight, gate resize on width change
- Stop blocking native scroll on the YES canvas’s touch handlers
- Quicksand: font-display optional, not swap, to stop the text-jump on scroll
- Use lvh/lvmin, not svh/svmin - svh was the wrong end of the viewport
- Update style/main.css
- Update style/main.css
- YES canvas: size off its own container rect, not window.innerHeight
- yes.js: freeze .hero-yes’s height via inline style, don’t trust svh alone
- add responsive container width
- deploy.yml: build and publish question_lint alongside the app binary
- Make aggregate state graphs content-driven, not compiled Rust
- One shared, content-labeled Confirm button per alternative instead of one per row
- get_resource/get_requirement_options: #[server(default)] on params
- Add optional Alternative.image and Feature.color/icon for richer layouts
- Drop the feature list’s left-padding
- Drop question-report entirely, obfuscate the mailto link instead
- Transition.from: state graphs deeper than one decision
- Alternative.images: array of urls, rendered as a Swiper card deck
- Reseed lost event history from the KV projection on transition
- Lighten the comment load
- Validate action targets; format timestamps client-side; clippy cleanup
- Question nav + gateway alternatives
- Responsible note under the title; followup pages out of the nav until a chain exists
- README: what the engine provides
- Feature icons: masked span painted by currentColor, not
- Responsible note: one line, same hue and size as the hero description
- Responsible note as
<small>: meta information, dimmer than the description - Responsible note back to the footer, above the question nav
- Bound inputs: a field that loads its value from a sibling-parameterized resource
- add some more width to our alternatives
- Accent to a dusty press-cyan, a muted echo of the canvas’s cyan ink
- Editor type in rem: prosekit’s px typography ignored the responsive root
- Ban px from stylesheets: every length rem/em off the responsive root
- No translucent input chrome: solid ink-dim placeholders and embed meta
- Editor type scales for real: drop prosekit typography.css, fix selectors
- Stable server fn endpoints: open tabs survive deploys
- Drop stray local tool temp file, ignore .claude/
- Noise-driven YES, sticky hero with frosted cards, full light theme
- increase translucency of laternative cards
- Showcase jq test tracks Gitea repo shape and website-first links
- Gesture input type, redoal-relay client, gitea_releases, content-driven branding
- Echo thumbnails fade in via timeout, not rAF
- Trigger deploy: redoal OAuth2 credentials now configured
- Deploy becomes publish: portal ships as a versioned release artifact
- Semver releases cut with cargo-release; publish on v* tags only
- Docs: release/rollout flow after the instance-ownership refactor
- README: frame portal as a generic multi-site question engine
- Design doc: filesystem routes, sections, dynamic segments
- Filesystem routes, sections, dynamic segments; instant YES hero
What each site runs
Read from the hosts on 2026-09-28; a rollout is a pin bump in the
site’s content repo (PORTAL_RELEASE in its deploy.yml) - on both
hosts now, since kasse’s runner deploys its own instances.
| Site | Host | Instance | Version |
|---|---|---|---|
| uhhm.no | ergo | app@uhhm-portal | v0.5.2 |
| redoal.com | ergo | app@redoal-portal | v0.5.2 |
| westra.klingenbergbygg.no | kasse | app@westra-portal | v0.5.2 |
| portal.klingenbergbygg.no | kasse | app@klingenberg-portal | v0.5.2 |
| vel.klingenbergbygg.no | kasse | tomtervel-portal-1 (podman, tomtervel/infrastructure) | v0.6.0, memberships |