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>
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>
/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>
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>
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>
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>
`mail: { to, subject, body }` on a state in aggregates.yaml sends when a
record enters that state, `to: submitter` or one address, templates
over the record's fields plus {site}, {chain}, {email}. An invite mails
its one-time link built in, reworded by `mail.invite` in site.yaml,
which also carries the sender (`mail.from`, `mail.reply_to`); without
it a site sends nothing and says so in the log. Portal renders the
text and an HTML alternative and publishes one message per state
change on portal.mail.send in the WORMHOLE stream, id <bucket>:<record>
:<state> so a retried transition sends once; uhhm/gdo holds the
durable consumer and talks SMTP. The lint requires a required email
field on every form recording into a bucket that mails the submitter.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
people: the Kanidm person lookup reads the body (200 + null is a
missing person in Kanidm 1.11).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three releases went out with no section because the edit that moves
Unreleased under a version heading was a no-op when no Unreleased
section existed, and nothing checked. Now the publish job fails when
the tag has no section.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
question_lint, needs_replay, the needs module and the local-checkout
loaders now live in uhhm/iris, which depends on this crate as a
library pinned to a release tag. The tarball ships the matching iris
binary (IRIS_RELEASE in publish.yml) in place of question_lint, so a
site that pins nothing extra keeps a lint at current/iris. Portal
loses about 1,100 lines and keeps only what runs at request time.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
The 0.3.34 rule listed every followup "further down" from the current
page, so the front door offered the thank-you pages before anything
was submitted, and a followup one typed in offered the ones beyond
it. The chain half was weaker than it looked too: it matched the
question the chain answered, which is a front door anyway.
Now a followup is offered only when the visitor has earned it: their
verified chain answered the alternative that leads there (the chain
index records the alternative from here on; older nodes fall back to
every recording alternative of the answered question, and nothing
beyond it), or it hangs off an earned page by a link that records
nothing. A page behind an answer they have not given is never offered.
The walk ends at any front door, so "back to the start" never fans out.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
Forty releases carried only a version bump in their commit, so what
changed lived in the commits between them and in the content repos'
pin bumps. The changelog is reconstructed from that history, newest
first, with an Unreleased section on top and a table of which version
each of the four sites runs. The publish workflow now posts the tag's
section as the Gitea release notes, and the README says to move the
Unreleased lines under a version heading before cutting.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH