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>
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>
`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>
`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
Who may do what to what was five checks in five files: `qualifies` on
a page, `requires_group`/`public` on a resource, a desk's transition
allow-list, a self_transition's email check, the automation token.
They are now compiled from content on every load into one Cedar
policy set (src/access.rs), and every server entry point asks it: open
and submit a page, read a resource, move a record, fire a self
transition, read a bucket as the automation service, extend an
invite. Each decision names the rules that granted it; refusals and
every mutation are published on portal.access.decided, and
portal.access.<site>.may answers the same question for other apps
over request-reply (proven live against uhhm's content). The policy
validates against a fixed schema, so a content shape the compiler
cannot express fails instead of granting nothing. Identity is still
Kanidm's; the browser keeps is_qualified as a rendering hint only.
`invite: {group}` on an alternative of a gated page onboards a person:
src/people.rs creates the account, records the email, adds the group
and mints a one-time credential link through Kanidm's REST API with a
service-account token (KANIDM_API_TOKEN, in idm_people_on_boarding).
The result is recorded with the answer into the runtime's
portal_invites bucket and announced on portal.people.invited. Validated:
an invite needs a gated page and required name and email fields.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
Every outbound fetch - Gitea content at boot and on reload, the raw
asset proxy, the repo-info endpoint, the content-declared url resource
- built its own reqwest client with no timeout and read whole bodies
with .text(). `crate::http` is now the one client (5 s connect, 20 s
per request, 5 redirects) with capped body readers: 2 MB for a url
resource, 8 MB for a Gitea reply, 32 MB for a proxied asset; one byte
over is an error, never a truncated document.
The upload handler read a whole field into memory before comparing it
to the 20 MB cap, and axum's 2 MB default body limit had made that cap
unreachable anyway. It now reads chunk by chunk and refuses the moment
the cap is crossed; the /upload route raises the body limit to match.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
A question may carry event: {starts, duration, place}. While the
window is open the page is announced in a strip at the top of every
header (name, when, 'in 3 days'), soonest first, and kept out of the
footer nav; when it closes the page becomes a followup - only a
visitor carrying an answer chain still sees it.
announce.rs keeps one record per event page in the runtime-owned
portal_events bucket (built-in state graph: announced ->
awaiting_summary -> summarized, content may override) and, on a
one-minute idempotent sweep, moves ended windows to awaiting_summary,
publishing the transition on portal.answers.submitted as 'Summary
due' - the post-what-happened task a review desk picks up. Lint
warns when event pages exist but nothing reads portal_events.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
site.yaml's hero is now plain | module, where module names a
JavaScript file the content repo ships (mount(container) -> handle
with stop()). Portal serves content assets same-origin at
/site/<path> (Gitea raw sends no CORS headers), starts the module at
HTML parse time, adopts it on hydration, mounts fresh via the inline
script's __mountHero on client-side navigation, and stops it on
leave. The YES canvas (yes.js) and the gesture hero mode leave the
engine - uhhm/questions ships YES as its hero.js, redoal/questions
ships a sine-swings band. Gesture form canvas no longer balloons after
a stroke: the wrap's aspect-ratio reservation is scoped to :empty
(pre-mount) so it can't turn echo-strip height into width inside the
flex field.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pkg files keep stable names across releases, so an uncontrolled
browser cache could pair last release's wasm with the new server's
server-fn wire format - every page then renders its error branch
(redoal.com's 'Nothing here' after v0.2.0). Cache-Control: no-cache
on /pkg and /yes.js makes clients revalidate (a 304 per load) instead
of guessing. The content-declared site title also moves out of the
question Suspense: remounting with each navigation lost the
leptos_meta race to the compile-time fallback, flipping redoal.com's
tab to 'uhhm' on the first client-side nav.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The questions/ tree is the router now: ids derive from file paths
(index.yaml names its directory; explicit id still wins for legacy
content), actions and requires_chain accept relative refs, nested
non-index files infer followup, and _section.yaml applies qualifies/
requires_chain/responsible to everything under its directory. Dynamic
[name].yaml pages serve any /dir/<value> with the segment substituted
into {name} resource-key placeholders; submissions index their chain
node in a portal_chains KV so requires_chain pages can verify a
visitor's ?chain= lineage actually ends at the required question.
Loading uses one recursive git-trees call; question_lint walks
subdirectories the same way. Implements docs/design/filesystem-routes.md.
Also: the YES hero now starts at HTML parse time via an inline module
script (yes.js moved to public/ for a stable /yes.js the wasm binding
raw_module-imports too - snippet paths are per-build-hashed), with
hydration adopting the running instance; and both gesture containers
reserve their box in CSS so mounting doesn't shift content.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four coupled additions that let one portal build serve a second face
(redoal.com) next to uhhm.no:
- type: gesture requirement - gesture.js draws a single stroke on a
DPR-aware canvas (pointer events, touch-action: none), mirrors
{points, key} into the paired hidden input prosekit-style, and -
when content declares relay: wss://... - speaks the redoal-relay
protocol: announce on stroke end, ghost the ack's decoded key path,
show echoes of similar strokes as thumbnails. Offline/broken relay
degrades to a plain drawing input; the widget handle's stop()
closes the socket on SPA navigation (yes.js lifecycle, not
prosekit's fire-and-forget). Submit re-parses gesture values so the
bucket stores a real object, not double-encoded JSON.
- gitea_releases resource source - token-authenticated
/repos/{owner}/{repo}/releases, for advertising a private repo's
releases (content pins url: null - private html_urls 404 publicly).
- site.yaml branding - optional, at the content repo root: title,
wordmark, hero {kind: yes|gesture|plain, relay}. Absent file means
the historical uhhm look, so uhhm changes nothing without a content
edit. Hot-swapped with questions/aggregates on content reload;
question_lint validates it in both --path and --repo modes.
- deploy.yml ships the same build twice: uhhm-portal (3010) as
before, redoal-portal (3020, CONTENT_REPO=redoal/questions,
redoal.com vhost). Needs host prep + REDOAL_OAUTH2_* repo
secrets/vars before the new steps succeed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
image: Option<String> becomes images: Vec<String> (nothing in live
content used the old field). One url renders as the plain banner it
was; several become a swipeable cards-effect deck via Swiper Element
12.2.0, vendored into public/ (MIT) like prosekit-editor.js/yes.js
rather than pulled from a CDN - the bundle only loads on pages where
some alternative actually declares more than one image.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Six hand-maintained copies of the bucket->aggregate-type table
(4 per-type State enums, content::aggregate_type_for_bucket +
is_valid_transition_target, answers.rs's two dispatch matches,
backfill_events.rs's own migration table) collapse into one:
questions/aggregates.yaml, loaded and hot-swapped in AppState.aggregates
the same way AppState.questions already is. aggregates/mod.rs's
replay/create/transition now run off a runtime-loaded AggregateSchema
instead of a compile-time AggregateKind trait impl per type - the
CAS/JetStream mechanics underneath are unchanged. A new aggregate type
is now a content-only change, no portal deploy required.
backfill_events.rs is deleted outright rather than ported - its
migration job was already done and production data here is minimal
and expendable (the EVENTS stream gets purged by hand post-deploy).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The header link was just styled "UHHM" text, unrelated to the actual
brand mark - swapped in the institutional logo (the three-arch
"building" mark, matching infrastructure/assets/brand/institutional-*.svg
and Gitea's own homepage logo) instead. Dark-stroke variant only -
portal is a single permanent dark theme, no light mode to switch for.
Same explicit-route treatment the favicon files needed (the /*any
wildcard route would otherwise swallow the request before the static
file handler gets a chance).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
favicon.svg was a plain solid circle, unrelated to the actual brand
mark - replaced with the real single-arch icon (matching
infrastructure/assets/icon-{light,dark}.svg), split into two
prefers-color-scheme-scoped files rather than one SVG with an embedded
@media query - the latter doesn't reliably re-evaluate via <link> on
Safari/iOS (confirmed by prior real-device testing on the Gitea side,
see gitea-head.tmpl).
Encouragements used to render right after the description, separated
from the actual submit button by the whole features/requirements
block - moved them into the same wrapper as the form, directly above
the button, so reassurance text and the call to action read together.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the free-string, direct-KV-mutate state model in answers.rs
with a proper event log (events/store.rs, JetStream-backed, CAS via
expected_last_subject_sequence) and three pure state machines
(aggregates/{applicant,subscriber,project}.rs). Closes a real
lost-update race in the old transition_answer (concurrent decisions on
the same item could both win, publishing contradictory events). KV
buckets become best-effort read-model projections, not the source of
truth. Content-declared transition targets are now validated at
load/reload time against the real compiled transition tables, not
accepted as arbitrary strings.
Buckets renamed to describe their content, not their relation to the
app (portal_applicants -> applicants, etc); "inquiry" folded into a
richer "project" concept.
ResourceSpec generalized beyond a single KV bucket: Kv | GiteaStarred |
GiteaOrgRepos | Url sources, with an optional jq filter (via the jaq
crate) to shape live data for the frontend. Url source is SSRF-guarded
(https-only, rejects loopback/private/link-local, real DNS resolve).
New headless question_lint binary (validates content against compiled
transition tables with no NATS/OIDC/server involved) and a one-time
backfill_events binary (dry-run by default) for migrating existing KV
data onto the new event log.
Questions get an optional `responsible` contact plus a lightweight
"report this question" action.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- type: prosekit requirement kind, backed by public/prosekit-editor.js
(ProseMirror via prosekit, loaded from esm.sh, no bundler) - mirrors
its HTML into a paired hidden input so it reuses the existing
RwSignal/on:input wiring.
- Pasting a project.uhhm.no/<owner>/<repo> URL in the editor embeds a
repo card, resolved server-side via a new /gitea-repo handler
(content::gitea_repo_handler) so the browser never needs Gitea API
CORS.
- New /automation/kv/{bucket} handler, bearer-token gated
(AUTOMATION_READ_TOKEN), for backing automations (n8n) to read a
NATS KV bucket without a browser session.
- Fix: a login started on one of apex/www set its session cookie
there, but Kanidm's redirect_uri is fixed to PUBLIC_URL - landing
the callback on a different, empty session ("no login in
progress"). Caddy now redirects www -> apex so every visit stays on
one canonical host.
questions is now Arc<ArcSwap<HashMap<...>>> - readers do a lock-free
atomic load (state.questions.load().get(&id).cloned()), never blocking
on or blocked by a reload. content::watch_for_reload subscribes to
portal.content.reload (published by the questions repo's own CI after
it lints a push - see that repo's lint-and-reload.yml) and swaps in a
freshly re-fetched HashMap on each message. A fetch/parse failure logs
and keeps serving the last-good content rather than clearing it.
load_questions_from_gitea (content.rs) fetches question YAML straight
from the questions repo's public Gitea contents API at startup instead
of scanning a local directory - one fewer moving part in production
(no git clone to keep in sync, no separate questions-repo deploy
workflow). Still just an in-memory startup load, same as before -
served from RAM for every request, no per-request network call.
Verified against the real repo (all 5 questions fetch correctly).
CONTENT_DIR is replaced by CONTENT_REPO/CONTENT_BRANCH, defaulting to
the real questions repo so local dev needs no env override.
Also adds .gitea/workflows/deploy.yml: builds with cargo-leptos,
ships the release under /srv/app/uhhm-portal (the generic app@.service
deploy layout), writes /etc/app/uhhm-portal.env from this repo's own
Actions Variables/Secrets, restarts the service, and drops this app's
Caddy routing snippet into services.d/.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Leptos/Axum app that renders a Question/Alternative/Feature schema
loaded from a sibling content repo (portal-content). Kanidm OIDC login,
content-driven authorization (Question.qualifies), a generic NATS
KV-backed resource + state-transition mechanism (no bespoke "applicant"
concept baked into the runtime - it's all content), a SHA-256 DAG chain
tying submissions and decisions together, and the "YES - Rasterized
Lines" piece (ported from the live uhhm.no site) as the landing hero.