Commit Graph
254 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug cb31995299 Release 0.5.10
docs / deploy (push) Successful in 4s
Test / test (push) Failing after 45s
Publish release / publish (push) Successful in 3m35s
v0.5.10
2026-09-30 14:10:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 579ad6f50d The lint lets an invite form ask for a phone number instead of an address
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:09:49 +02:00
Bendik Aagaard Lynghaug 89d419ff7b Release 0.5.9
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 58s
Publish release / publish (push) Successful in 3m36s
v0.5.9
2026-09-30 14:06:58 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 00571f65bd Changelog: 0.5.9
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:06:56 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 c4e2de5efa Memberships beside Kanidm: the invite makes the account too
Kanidm makes people, portal makes members. A site can switch to
memberships before its sign-in provider is one everyone already has.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:06:55 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 5bc021de77 Docs: vel.klingenbergbygg.no runs v0.5.8
docs / deploy (push) Failing after 2s
Test / test (push) Failing after 25s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:55:49 +02:00
Bendik Aagaard Lynghaug f4ed98322f Release 0.5.8
docs / deploy (push) Failing after 3s
Test / test (push) Failing after 59s
Publish release / publish (push) Successful in 3m22s
v0.5.8
2026-09-30 13:40:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 bce9ad13e4 Changelog: 0.5.8
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 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 Lynghaug cc26a232ec Release 0.5.7
docs / deploy (push) Failing after 3s
Test / test (push) Successful in 58s
Publish release / publish (push) Successful in 3m21s
v0.5.7
2026-09-30 13:33:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 77f2880e83 Changelog: 0.5.7
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:33:12 +02:00
bl 23f98add51 Merge pull request 'Memberships: groups kept by portal itself, a person by phone number first' (#6) from memberships into main
docs / deploy (push) Failing after 3s
Test / test (push) Successful in 58s
2026-09-30 11:33:12 +00: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 b9761cec63 Docs: vel.klingenbergbygg.no runs v0.5.6
Test / test (push) Failing after 40s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:28:05 +02:00
Bendik Aagaard Lynghaug b9327a170f Release 0.5.6
Test / test (push) Successful in 55s
Publish release / publish (push) Successful in 3m9s
v0.5.6
2026-09-30 08:18:41 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 789cbf7977 Changelog: 0.5.6
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:18:39 +02:00
bl 6acb7e6a30 Merge pull request 'A group may hold a page's responsibility' (#5) from group-responsible into main
Test / test (push) Successful in 55s
2026-09-30 06:18:38 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 014594283a A group may hold a page's responsibility
Test / test (pull_request) Successful in 56s
responsible: { name, contact, group } says the address is a group's
inbox, not a person: no account or mail is made for it, and any
signed-in member of the group gets the suggest-a-change card. A
board's shared address was onboarded as a person before this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:17:27 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 de65392917 Docs: vel.klingenbergbygg.no runs v0.5.5
Test / test (push) Failing after 56s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:04:35 +02:00
Bendik Aagaard Lynghaug 77867090ed Release 0.5.5
Test / test (push) Successful in 55s
Publish release / publish (push) Successful in 3m29s
v0.5.5
2026-09-30 07:51:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 c24e117f67 Changelog: 0.5.5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 07:51:04 +02:00
bl 95928f7265 Merge pull request 'One record answers another, from a list' (#4) from answer-a-record into main
Test / test (push) Successful in 57s
2026-09-30 05:51:04 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f0e50477c6 aggregates.yaml accepts a mail to a field of the record this one belongs to
Test / test (pull_request) Successful in 55s
The parser refused {notice.email} before the mailer could resolve it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 06:55:27 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 fc998c8592 One record answers another, from a list
Test / test (pull_request) Successful in 56s
Three small things that together make a board work: a link on every
listed record to a page about it (ResourceSpec.link, {id} and {field}
in the address); a state mail whose `to` is a field of the record this
one belongs to ({notice.email}), with that record's fields in the
text, and a lint that the form carries the record field it reaches
through; and value: "{user.email}" filled from the session and hidden,
so someone the site knows is not asked who they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 06:47:36 +02:00
bl 470338b969 Merge pull request 'The way down the tree is the link at the foot, not an arrow that follows' (#3) from foot-only into main
Test / test (push) Successful in 55s
2026-09-29 19:24:01 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 86ec51012e The way down the tree is the link at the foot, not an arrow that follows
Test / test (pull_request) Successful in 56s
0.5.3 put the same link in two places: in words at the foot of the
page, and as an arrow fixed at the left edge half-way up the screen.
The arrow stayed with the reader the whole way down a page to offer
what the foot offers where they end up anyway. It is removed; the foot
link and where it leads are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:22:54 +02:00
Bendik Aagaard Lynghaug 48a1a58d01 Release 0.5.4
Test / test (push) Successful in 55s
Publish release / publish (push) Successful in 3m10s
v0.5.4
2026-09-29 21:11:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 72b7945ab4 Changelog: 0.5.4
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:11:11 +02:00
bl a4c39e5ebc Merge pull request 'Accounts the content mandates, a seed for the first person, and edits suggested by who is responsible' (#2) from seed-admin into main
Test / test (push) Successful in 55s
2026-09-29 19:10:40 +00: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 7a50ce77b2 Docs: getting started says what is retried and what waits for the next start
Test / test (pull_request) Successful in 51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 18:03:51 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 641c5f1bd3 Docs: getting started, bringing your people in
Test / test (pull_request) Successful in 51s
From an empty Kanidm to everyone at their own desk: what in the content
decides who needs an account, the four things Kanidm needs for portal,
the first person, what happens at start, how everyone after comes in
from the desks, what the people who ask the questions can do, and what
each failure looks like.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 18:02:02 +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 LynghaugandClaude Opus 5.5 bd174224fe Docs: vel.klingenbergbygg.no runs v0.5.3, from its own podman stack
Test / test (push) Failing after 56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 13:38:32 +02:00
Bendik Aagaard Lynghaug 46d19e6d9d Release 0.5.3
Test / test (push) Successful in 51s
Publish release / publish (push) Successful in 2m59s
v0.5.3
2026-09-29 13:30:22 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 9229fc1674 Changelog: 0.5.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 13:30:12 +02:00
bl c9d52c6005 Merge pull request 'A way down the tree from every page' (#1) from down-the-tree into main
Test / test (push) Successful in 54s
2026-09-29 11:29:57 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 e2596bc3cf A way down the tree from every page
Test / test (pull_request) Successful in 53s
An arrow fixed at the left edge, half-way up the screen, and the same
link in words at the foot of the page. It goes one level down: the
nearest page below the current path that the visitor may open, found by
taking the path apart a segment at a time, and the front door when none
of those is a page (/status/oppslag -> /, /review/abc -> /review). The
front door itself has none. Labelled with where it goes: 'Tilbake til
starten', or the target page's question in quotation marks.

Content stops needing a 'back' alternative on every page, and a visitor
who went up a branch whose context did not apply is never stranded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 07:05:39 +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 Lynghaug 45240b7cab Release 0.5.2
Test / test (push) Successful in 57s
Publish release / publish (push) Successful in 3m1s
v0.5.2
2026-09-28 18:09:28 +02:00
Bendik Aagaard Lynghaug 6ee6551400 Release 0.5.1
Test / test (push) Successful in 58s
Publish release / publish (push) Successful in 3m2s
v0.5.1
2026-09-28 17:48:37 +02:00
Bendik Aagaard Lynghaug 68471969b5 Release 0.5.0
Test / test (push) Successful in 1m0s
Publish release / publish (push) Successful in 6m47s
v0.5.0
2026-09-28 16:46:50 +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 8d6f29011c An upload asks the policy, like every other way in
/upload asked nothing. It took a file, capped it at 20 MB, and wrote
it to the bucket for whoever sent it - no session, no page, no policy.
It was the one server entry point that did not ask the question every
other one asks, and on a site whose forms are public that makes the
route public too: a free disk for anyone who finds the host.

Harmless while no instance had storage configured, which is why it sat
there. kasse now has a garage wired to klingenberg's portal, so it is
not harmless any more.

An upload now carries the question and the alternative the file is
being attached to. The page has to exist, the alternative has to be
open and to actually declare a `type: file` field, and the policy has
to allow Submit on that page for whoever is asking - checked after the
bytes are read and before any of them are written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 16:08:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 9891c462c6 A page says nothing until it has something to say
The Suspense fallback on a question is the whole served page until
the question resolves - no header, no wordmark - so the word
"loading…" was the first and only thing a visitor or a crawler read
about the site. True, and not about them. It holds its space and
stays quiet now; `aria-busy` says what is happening to a reader that
would otherwise find an empty document. Same for the two resource
slots inside a page, which keep a line's worth of room.

Also gone: the `gitea_org_repos` resource kind, which no site ever
named. `gitea_starred` and `gitea_releases` stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 13:25:11 +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
Bendik Aagaard LynghaugandClaude Opus 5 26c1762dfa A reply to a case mail becomes a note on its record
Test / test (push) Failing after 53s
Publish release / publish (push) Successful in 3m14s
Whatever reads the mailbox publishes on portal.mail.received; portal
appends the note after checking the record exists in a bucket this
site has and the sender is the address the record itself holds. A
note never moves a case: a mailbox is the one door anyone may knock
on, so it cannot drive a state machine even if someone forges their
way past the checks. The desks show the conversation beside the case.

Tested: the address parser, every refusal (a stranger, no address to
compare, an empty reply, the same reply twice, an unknown contract),
and the whole path through a real JetStream - the note lands, the
state does not move, and no event is written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.4.0
2026-09-24 21:26:57 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 4fd60293fa A field can be a place, and a question can be about one
`type: location` puts a pin on a map and stores {lat, lon, zoom}; a
record that holds one renders as a still, on a desk and in any
resource card. `place:` on a question is the counterpart to `event:`:
event makes a page an occasion with a time, place makes it somewhere,
shows the map under the question, and opens any location field on the
page there instead of on the globe.

Every tile and every still is fetched by portal and served from this
origin (src/maps.rs, MapLibre vendored in public/), so a page with a
map on it still talks to nobody but us: no third-party request, no
consent banner, and the token never leaves the server. MAPBOX_TOKEN
unset turns the whole thing off, the way GARAGE_* does for uploads.

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