11 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Opus 5.5 f2b131ebbb KANIDM_GROUP_PREFIX: plain group names in content on a shared Kanidm
Test / test (pull_request) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:34:21 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f395ace556 Storylines: the same site told several ways, one per visitor, counted
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m1s
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 and reads it on every page and in every mail about their case.
Records and events carry the storyline; GET /report gives counts per
bucket, storyline and state, and nothing else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:11:27 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 6c86dc47a2 The phone scope is asked for only when told to: Kanidm denies a scope the person lacks
0.5.7 asked every provider for phoneNumber, so a Kanidm site could
not sign anyone in. OIDC_EXTRA_SCOPES=phoneNumber asks for it (Vipps).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:40:12 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 a9b5a81ebd Docs as a site: portal.uhhm.no from docs/ with mdBook, put to git-pages
Test / test (pull_request) Successful in 58s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:31:23 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f047e21c4a Memberships: groups kept by portal itself, a person by phone number first
Test / test (pull_request) Successful in 58s
A membership is a case in the built-in portal_memberships bucket:
invited by a desk, a grants, the seed or the responsible pass; active
from the person's first sign-in, when the provider's subject, phone
number or address matches the record; removed by a desk, and invited
again after that. One record per group and person however it was
keyed, and at sign-in every record that matched learns what the
provider verified. The active memberships join the groups claim, so
Cedar sees the same parents as before.

PEOPLE_BACKEND picks Kanidm or memberships; unset, a KANIDM_API_TOKEN
means Kanidm. The NATS access service looks a person's groups up when
a question names them by sub, phone or email and lists none. The
sign-in asks for the phoneNumber scope and reads it from userinfo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:28:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 ef4789f9b2 Invites and suggestions are cases; the responsible follow the content
Test / test (pull_request) Successful in 56s
Five things the first cut of accounts-from-content left open.

The first person and the responsible people are set up side by side,
and both try again a minute apart: a seed that waited twenty minutes on
Kanidm used to hold everyone else up, and a responsible person who
failed waited for the next restart.

The responsible are gone over again whenever the content changes.
watch_for_reload publishes portal.content.reloaded once new content is
in place, and seed.rs listens.

Being responsible ends when the content stops saying so: someone no
page names any more leaves RESPONSIBLE_GROUP in the same pass, if
portal is who put them there (Invited.already_member). The account
stays.

An invite keeps itself current. portal_invites has an expired state;
signing in closes a person's invites, a sweep moves the ones whose link
ran out, and a desk's move from expired back to open makes a fresh link
and mails it. Invites made at start are on file with the desks'.

A suggested edit is a case in the built-in portal_edit_suggestions
bucket, the person who made it is mailed at each move, and the card
lists what they sent and where it stands.

Found on the way, against Kanidm 1.11.2: a link's end comes as seconds
since the epoch, which portal did not read, so invite mails said
"until" and nothing; and a mail search answered with a 5xx was taken
for a refusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:00:10 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 0886869607 Accounts the content mandates, a seed for the first person, and edits suggested by who is responsible
Test / test (pull_request) Successful in 51s
Every person a page names as responsible gets a Kanidm account at start
if they have none, and a mail saying they are listed as responsible for
asking that question, with the link to sign in; RESPONSIBLE_GROUP puts
them in one group. SEED_ADMIN_EMAIL invites the first person into the
most privileged group - the one whose desk may invite into the most -
once. Signed in, a responsible person may suggest a change to their
own pages; checked on the server, kept, published, and mailed to the
site's inbox.

Fixes an invite of someone new on a Kanidm with only the onboarding
account: the person is created with their address in one call, since
idm_people_on_boarding may create but not modify (it answered 403).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 15:44:20 +02:00
Bendik Aagaard Lynghaug f0c43c5c0f Docs: both hosts deploy from CI; the site table reads v0.5.2
Test / test (push) Failing after 1m7s
2026-09-28 18:26:31 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 214d4eb85c Sessions outlive the deploy that interrupted them
Test / test (push) Successful in 58s
A MemoryStore meant every restart signed out every desk on the site
being deployed. Bearable while a desk is three people, and not the
kind of thing that gets better on its own.

They live in NATS KV now: the JetStream already under the events, the
projections, the content reload and the access questions. No new
service, no new store, and the same bucket a second instance of a site
would read if there ever is one. `create` draws a fresh id rather than
writing over a session already at that key, the bucket's `max_age`
collects what nobody comes back for, and a record's own expiry is what
answers a load, whatever the bucket still holds.

Proved the only way it can be: a store opened twice against one
JetStream, which is what two runs of the binary are.

Also fixed, while there: the integration test asserted a record's
projected state was one that `aggregates::transition` never writes -
it appends events, the desk handler updates the projection - so it
failed whenever it was actually run. It now asserts what it says it
does, that a mailed reply leaves the state alone. test.yml has been
red since 0.3.47 while publish.yml stayed green, which is how v0.4.0
went out over it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 16:38:05 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 d5de25e8c6 A file can come back out, and a script cannot come back forever
Two halves of the same evening.

Uploads were one-way. A `type: file` field put an object in the bucket
and wrote its key into the record, and nothing ever read it back - a
desk showed `0aba5734-...-drawing.pdf` as a string with no link, so a
builder could receive drawings and never open one.
`GET /attachment/{bucket}/{record}/{field}` streams it. The key is not
the credential and is not taken from the caller: it is read out of the
record named, so a file is reachable only through a record somebody is
allowed to see, and a key that leaks off a desk is a link to nothing.

And there was no rate limiting anywhere. A burst of 30 per caller IP,
refilled one every two seconds, outside the session layer so anyone
over it is turned away before a handler is entered. Well above a
person filling in a form - a page load fetches several resources at
once - and well below being a nuisance. PORTAL_RATE_BURST and
PORTAL_RATE_SECONDS retune it; either 0 turns it off for an instance
behind something that already does this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:43 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 f18d1a5181 A person stays in dialogue with their case
Five things a building authority needed and portal could not say,
found by building one and replaying its cases through the engine.

The one that mattered: a submitter could only ever act on a record
nobody had touched. `access.rs` pinned a self_transition's from-state
to `open`, though the runtime had always passed the real state and
asked. So the applicant written to for a missing drawing could not
send it, and nobody could withdraw once anything had happened.
`self_transition` now takes `from: [state, ...]` and compiles one rule
per state; omitted it still means the initial state alone, which is
what every unsubscribe link already meant.

Then the four beside it. A record can belong to another record
(`type: record`, `of:`), with `where: { field, equals }` on a listing
as the other half, so an application's page lists the objections that
name it. A public view can be narrowed to the states worth showing,
so a register that is public by law is public about what was decided
and not about what was withdrawn. A state can grant a group, so an
approval that means membership needs no second step. And a state can
be moved by a clock (`after: { days, to }`, src/deadlines.rs) - held
to the same declared edge as a desk button, firing the same mail,
publishing the same event.

Also: the README is a README again, and everything that was crowding
it is in docs/ - architecture, content reference, state machines,
access, operations.

95 tests, up from 82.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 21:35:25 +02:00