Compare commits

...
106 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug 46d19e6d9d Release 0.5.3
Test / test (push) Successful in 51s
Publish release / publish (push) Successful in 2m59s
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
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
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
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>
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
Bendik Aagaard LynghaugandClaude Fable 5.1 9ed8194484 Release 0.3.47
Test / test (push) Failing after 55s
Publish release / publish (push) Successful in 3m48s
iris learns stage_words; portal unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 05:17:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 2ac34b6167 Release 0.3.46
Test / test (push) Successful in 1m7s
Publish release / publish (push) Successful in 3m45s
An invite finds an existing account by its email and only adds it to
the group.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:18:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 3c68814030 Release 0.3.45
Test / test (push) Successful in 1m3s
Publish release / publish (push) Successful in 3m18s
A mail stream that cannot be created at boot is a warning, not a
failed start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 21:24:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 0c68396d5b Release 0.3.44
Test / test (push) Failing after 1m3s
Publish release / publish (push) Successful in 3m6s
The mail stream is `mail`, not shouted; gdo 0.1.2 matches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 21:15:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 1ca3ac6807 Release 0.3.43
Test / test (push) Successful in 49s
Publish release / publish (push) Successful in 2m53s
Mail as content, delivered by uhhm/gdo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 19:45:50 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 19c8f541de Mail is content: a state's mail: and the invite mail, published for gdo
`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
2026-09-23 19:45:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 6d60f8b9a3 Release 0.3.42
Test / test (push) Successful in 52s
Publish release / publish (push) Successful in 2m57s
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>
2026-09-23 14:33:26 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f36d88a69c people: a missing person is 200 + null in Kanidm 1.11, not 404
The existence check went by status alone, so every invitee looked like
an existing account: creation was skipped and the group step failed.
Read the body; `null` means nobody there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:33:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 ef46b463e4 CHANGELOG: kasse sites run v0.3.41
Test / test (push) Failing after 23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 12:57:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 4818c5a26f CHANGELOG: the sections for 0.3.39 to 0.3.41; publish refuses a tag without one
Test / test (push) Failing after 28s
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
2026-09-23 11:54:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 7106543089 Release 0.3.41
Test / test (push) Failing after 51s
Publish release / publish (push) Successful in 2m50s
publish: the iris step retries on a fresh clone when its push loses a
race against a push to iris main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 11:53:00 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 44d5ef3683 Release 0.3.40
Test / test (push) Successful in 48s
Publish release / publish (push) Failing after 5m46s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 11:44:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 eeeb89b217 One access policy from content; onboarding into Kanidm as a form
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
2026-09-23 11:44:39 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 7408fa972a CHANGELOG: vel.klingenbergbygg.no runs on kasse too
Test / test (push) Failing after 9s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 06:08:51 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 d45dbe672a CHANGELOG: all four sites run v0.3.36
Test / test (push) Failing after 23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 06:06:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 7f23ef2de0 Release 0.3.39
Publish release / publish (push) Successful in 2m39s
Test / test (push) Failing after 21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 06:00:17 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e02c18acab publish: release the matching iris by proxy
Test / test (push) Failing after 24s
iris is versioned as portal. On a tag push this job clones uhhm/iris,
re-pins its portal dependency to the tag, sets its version to match,
builds and tests it, writes the changelog line, and pushes the commit
and tag - which iris's own CI publishes. The tarball ships the binary
just built. A portal change to something the lint reads fails the
release at this step. Needs the IRIS_PUSH_USER variable and
IRIS_PUSH_TOKEN secret on this repo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 22:05:31 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 dcc67ff2cd Release 0.3.38
Publish release / publish (push) Successful in 1m17s
Test / test (push) Failing after 11s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:54:08 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9879e1f5cf publish: writable cargo home for the iris build step
Test / test (push) Failing after 52s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:50:53 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 050c2350f4 Release 0.3.37
Test / test (push) Successful in 41s
Publish release / publish (push) Failing after 1m7s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:48:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 1ebd800fda The lint moves out to uhhm/iris
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
2026-09-22 17:29:26 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 623399f25d CHANGELOG: ergo sites run v0.3.36
Test / test (push) Successful in 1m3s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:03:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e4e97a1ada Release 0.3.36
Test / test (push) Successful in 58s
Publish release / publish (push) Successful in 2m6s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 16:58:47 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e67e841f8c Question nav: a followup is offered only once it is earned
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
2026-09-22 16:58:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 366ff51a0a CHANGELOG.md: one line per change, and what each site runs
Test / test (push) Successful in 49s
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
2026-09-22 16:51:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e06346e43a tests/jetstream.rs: the store, event log and projection against a real JetStream
Until now nothing outside a live site exercised the NATS side: the
aggregate engine was tested by in-memory replay only. This test
submits an answer, moves it from a desk through the engine, checks
that an undeclared move and an unknown record are refused, and reads
the record back from its own event log. It runs when
PORTAL_TEST_NATS_URL is set; CI starts a throwaway nats:2.14.6-alpine
with JetStream for it and tears it down. Without the variable the
test passes as skipped, so `cargo test` stays green offline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 16:51:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f909657c14 content: split by concern, every path still content::x
content.rs was 2,373 lines doing five jobs. It is now a directory:
mod.rs keeps the types and the resolution rules (router, refs,
reachability, build_questions) and the tests; gitea.rs loads from
Gitea and watches for reload, through crate::http; validate.rs holds
the checks a repo must pass; markdown.rs the inline markdown;
handlers.rs the Axum routes. Each submodule is re-exported from
content, so no caller changes. The loader module is server-only and
gated as such. No behaviour change; 65 unit tests unchanged and green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 16:51:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 c91bf59946 One bounded HTTP client for every server-side fetch; uploads refused while streaming
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
2026-09-22 16:51:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 d7f60eaa31 needs: also_moved_by - a need carried by more than one group's desk
Test / test (push) Successful in 27s
Roles overlap on one aggregate: a project manager prices a change, a
site manager completes it. A need may list the other groups whose
desks hold some of its moves; their buttons count toward reaching
`done_when` and toward the strand check exactly as `handled_by`'s do.
`handled_by` must still hold a desk of its own, so that someone is
answerable for the record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 21:46:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 6efd7a0c6b needs_replay: make simulated cases real aggregates, report buckets by state
Test / test (push) Successful in 26s
A simulation that lives in JSON never meets the engine. needs_replay
takes a simulation's cases and runs them through the code the site
runs: answers::store_answer for the submission, aggregates::transition
for each desk move, against a real JetStream with its event log and
its refusal of undeclared moves. Buckets are then read back by
replaying each record's events and reported per bucket against
`done_when` and `success`. `--report-only` takes the same scorecard
from any JetStream, a live site's included.

First run, 30 cases from a generated contractor site on a throwaway
in-memory NATS: 30 aggregates, 56 transitions accepted, none refused.
The same routes against the earlier machine that skipped a stage: 8
refused, 8 of 12 enquiries left in `open`.

The local-checkout loaders move from the lint binary to `portal::local`
so both tools share them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 19:48:24 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 cbbc1c0da5 needs: stages - every stage the business named must be a state
Test / test (push) Successful in 34s
Found by simulating a generated site: the owner wrote "they come in,
we go and look at the building, we send a quote", and the generated
machine went open -> quoted. Nothing compared the machine to what the
business said, so it passed. A need may now list its stages as the
business describes them; each must be a state of the bucket and
reachable through the handling group's desk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 19:03:42 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 7f90c14554 needs: success states and a resolved simulation model
Test / test (push) Successful in 31s
A need may name which of its `done_when` states are a good outcome
for the business ("lost" is finished, "won" is success), checked to be
a subset of `done_when`.

`question_lint --needs-sim <model.json>` writes everything a
simulation of the business needs, resolved the way the runtime
resolves content: each need's path with the choice on every page and
the form that gets filled, each bucket's state machine with every
desk's labelled buttons, and the personas. Portal exports it because
only portal loads content correctly; the simulator (uhhm/portal-trainer)
reads plain JSON.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 18:59:32 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 188f9bf574 Business needs as a checkable spec: needs.yaml + question_lint pass
Test / test (push) Successful in 27s
A content repo may now state what the business needs the site to do,
apart from how the pages word it. question_lint checks each need
against the pages and aggregates.yaml:

- a path of submissions gets the answer into `lands_in`, within
  `max_steps` and a required-field budget (`max_fields`);
- `handled_by` holds a desk over that bucket;
- every `done_when` state is reachable through the moves that desk
  (or a self_transition) offers, and no record strands on the way;
- a disabled alternative is named as the reason when it is one;
- `planned: true` reports the gap as a warning instead of failing.

Personas - visitors in their own words - become typed `choice` tasks
(the page's question, its alternatives as the options) written as
JSONL by `--needs-tasks` (`--skim`: headings only). Any external
answering engine returns `{id, choice, confidence}` lines, graded by
`--needs-score [--min-accuracy]`: a misroute is a wording defect. The
engine stays outside this crate on purpose so classifiers and LLMs
can be compared on the same tasks. The runtime never reads the file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 16:29:04 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 75000421ae Release 0.3.35
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m40s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-12 11:50:36 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9bcf46acd8 Item cards: no paragraph margin under the description
`.item-card` is a grid with a 0.4rem gap; the global `p` bottom
margin (0.9em) stacked on top of it under every description.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-12 11:50:31 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b24b4f5582 Release 0.3.34
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m42s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-11 19:40:51 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 74bba2e9b1 Question nav: offer followups by reach, not by carrying any chain
`list_qualifying_questions` reduced the visitor's `?chain=` to a
bool, so one followup path unlocked every followup on the site -
redoal's `/watching` (an email-link page whose header says "never in
the nav") showed up for anyone who had just subscribed.

The nav now resolves the chain hash to the one question it verifiably
answered (the same `lookup_node` a `requires_chain` gate uses) and
offers a followup only when it is on that path or further down from
the current page over `action` edges. `content::outcomes_reachable_from`
walks those edges through followup pages only, so a "back to the
start" action ends the walk at the front door instead of fanning out.
Templated actions (`/shape/{curve.key}`) resolve to their dynamic
page's pattern. No schema change: the `followup` flag keeps meaning
"not a front door", and the edges were already in the content.

Also: `main.loading`/`main.not-found` gets the same `svh` ladder as
`.hero-piece`, the last bare `vh` in the stylesheet, so wrapper
browsers that resize the webview as their toolbar animates don't
reflow the placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-11 19:40:46 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 bd12dd4e99 Release 0.3.33
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-02 11:48:42 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b044780e61 Head preloads: latin font, wordmark, content-host preconnect
Fonts hide behind the stylesheet - the browser parses CSS before it
finds them - so the latin Quicksand subset (every page's critical
path) is announced in the shell head; latin-ext/vietnamese stay
unicode-range-gated. The wordmark preloads from the resolved site
config, with a preconnect to its origin when it lives on the content
host (redoal.com's does), opening that TLS session early.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-02 11:48:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 9c22b52fda Release 0.3.32
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m43s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 22:12:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 01504fb120 Per-site favicon via site.yaml
A content repo can ship a favicon (svg/png/ico) named in site.yaml;
portal serves it at /site/<path> and injects a <link rel=icon> after
the static defaults so it wins. Also serves .ico assets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 22:12:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b25a5839b0 Release 0.3.31
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m45s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:48:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c4609f4370 Markdown links allow tel:
Phone numbers can now be [+47 …](tel:+47…) links in descriptions,
same gate as https/mailto/relative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:48:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 2c94ba4379 Release 0.3.30
Test / test (push) Successful in 28s
Publish release / publish (push) Successful in 1m50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:42:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 065bd0a86a Single alt-image shows natural height
Was cropped to a 14rem band via object-fit: cover; now shows the whole
image at natural aspect, bounded at 32rem so a tall one can't tower.
The image deck keeps its fixed-height cards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:42:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 035ad7830b Release 0.3.29
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m45s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:36:57 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 bf7f2e11e7 Markdown image layout hints + AVIF assets
Content-only image control: a markdown image title carries layout —
left/right to float text around it, a number for max-width in rem
(validated, capped) — translated to md-float-* classes and a numeric
inline max-width. No arbitrary CSS reaches the page. Portal also now
serves .avif site assets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:36:57 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 57b70c8445 Release 0.3.28
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:31:02 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0d3221c22f Hero description renders inline markdown
The question-level description (hero subtitle) was plain text, so
**bold** showed literal asterisks. Now it runs through
render_inline_markdown like every other description.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:31:02 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 013fd7151e Release 0.3.27
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m40s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:26:34 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 99b29bb09f wordmark_invert: explicit, not inferred from .svg
A content-shipped colour logo (Klingenberg's red tile) was being
inverted to teal in light theme by the .svg heuristic. Invert is now
an explicit site.yaml flag defaulting to house-mark-only; content
logos keep their colours unless they opt in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:26:34 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b08e1af139 Release 0.3.26
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:20:50 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c1cdc53187 Select fields: inline static options
A select requirement can now declare a plain options: [A, B, C] list
instead of a resource - each string is both value and label. Restores
the ancestor question format's inline enums (used by the klingenberg
port) without needing a resource endpoint for a fixed list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:20:50 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 4fee02ab82 Release 0.3.25
Test / test (push) Successful in 28s
Publish release / publish (push) Successful in 1m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:12:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 405bb98185 Content-shipped stylesheet override (site.yaml stylesheet)
A content repo can now ship a CSS file named in site.yaml's
stylesheet field; portal serves it same-origin at /site/<path> and
leptos_meta appends it after the default stylesheet so content rules
win. Plain repo-relative .css path only, validated on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:12:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 7fd40aaca9 Release 0.3.24
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:01:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 de6a90bbf5 Gateway submit-as-link loses the underline
A no-requirements gateway alternative renders .alt-submit as an <a>
for plain navigation; strip the link underline so it reads as the
button it's styled to be.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:01:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 88b1b96322 Release 0.3.23
Test / test (push) Successful in 28s
Publish release / publish (push) Successful in 1m44s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:23:33 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b259bf39bb Chrome speaks the site's language: site.yaml lang + i18n table
The strings the portal itself says - Asked by, Also worth asking,
Sign in, the not-found page, defaults - translate via a small en/no
table selected by site.yaml's lang, which also sets the html lang
attribute. Content keeps speaking for itself; unknown languages fall
back to English per key.

Also: a scheme-gated markdown image now swallows its alt text instead
of leaking it as stray text - this is the fix for the test that has
been red since 0.3.19 (publish is tag-triggered and does not gate on
the test workflow; caught late).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:23:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 528e2badaf Release 0.3.22
Test / test (push) Failing after 25s
Publish release / publish (push) Successful in 1m39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:17:26 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 f4e9f6d0ba site.yaml wordmark_height; raster logos keep their colors
Custom wordmark sizing per site (0.5-6 rem, clamped at render), and
the light-theme invert now applies only to .svg wordmarks - the
white-stroke house style - so a client's raster logo is never
recolored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:17:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 3b2cdc07b7 Release 0.3.21
Test / test (push) Failing after 29s
Publish release / publish (push) Successful in 1m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:10:49 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 2de936d995 Hero paragraph centers its measure box
max-width: 46ch without auto margins left-anchored the box inside the
centered column; the text centered in the wrong frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:10:44 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 9fca79b8ee Release 0.3.20
Test / test (push) Failing after 25s
Publish release / publish (push) Successful in 1m36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:54:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 3d51aa1e6a Sign-in becomes optional: KANIDM_URL unset disables auth cleanly
A content-only instance (westra preview) has no review desk and no
Kanidm client; booting no longer demands one. Auth routes answer 503
'sign-in is not configured on this instance'; everything public
renders as usual.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:54:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 d5eb362c87 Release 0.3.19
Test / test (push) Failing after 25s
Publish release / publish (push) Successful in 1m40s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:47:01 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 7f10ba20d3 Markdown images in descriptions, gated and framed
Images already flowed through render_inline_markdown ungated; they
now take the same scheme gate as links (https or same-origin only -
no data:, no plain http) and render as full-width framed figures in
alternative, feature, and item-card descriptions. Carries whole-site
imagery for content-driven instances (westra).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:46:56 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 756818cacd Release 0.3.18
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 15:58:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0423e9c368 Merge convergence: ink-to-key animation + overlay results
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 15:58:41 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ea7acf4d7d Gesture results overlay the canvas - the page never jumps
Echo thumbnails float centered along the canvas's bottom edge and the
empty-state line sits in the same band; both are out of flow, so the
widget's height is fixed by the canvas alone whether results come
back or not. The strip is pointer-inert except the thumbnails
themselves, so drawing near the bottom edge still works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 15:55:58 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 19f8fbaf03 Convergence: ink converges onto the decoded key (WebGL glow)
On ack (and on picking a place) a glowing copy of the stroke peels
off and converges point-by-point onto the key's decoded path - a
staggered wave from stroke start to end, comet trails, additive on
dark / ink on light, ghost deposited where the light settles. WebGL
point sprites on an overlay canvas; no WebGL or reduced-motion means
exactly the previous behavior. ?relay= query override points widgets
at a local relay for dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 15:47:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ee295be6a7 Item cards render inline markdown descriptions
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m39s
Resource-fed cards follow the same convention as every other
description: links live there, sanitized by render_inline_markdown.
Carries the per-recording report link on place pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-31 17:18:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 d161677155 Release 0.3.16
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m33s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-30 23:13:33 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 8229dbf4c2 Announce sweeper: refresh open records from content
A date or place corrected after first announce never reached the
portal_events record, so the followup fired on the stale schedule.
While a record is still in its initial state, content is the source
of truth: differing responses are written back on each sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-30 23:13:29 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 3de069f41d Release 0.3.15
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m33s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-30 23:09:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ab3a649646 Hidden fields: carrier value without a label row
A type: hidden requirement rendered through the fallback branch, whose
label wrapper shows the field name — so a preset key listed as a second
visible field under the email. Bare hidden input, no row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-30 23:08:59 +02:00
Bendik Aagaard Lynghaug f1e7573b36 Release 0.3.14
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m33s
2026-08-30 16:56:19 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 2082e8ae48 Feature cards: icon column, one text edge
The icon floated, so a description's second line wrapped back under
it. With an icon the feature is a two-column grid: icon left, every
other child in the text column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:56:18 +02:00
Bendik Aagaard Lynghaug 313126768b Release 0.3.13
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m33s
2026-08-30 16:36:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 78953a4633 Voice field: nothing leaves the browser until Keep it here
The widget holds the alternative's submit: on the first press it
uploads, sets its value on the relay's confirmation, then lets the
submit through. A relay error keeps the recording for another try and
submits nothing. Status copy says what to do at each step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:36:14 +02:00
Bendik Aagaard Lynghaug 703e6b6356 Release 0.3.12
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m32s
2026-08-30 16:06:58 +02:00
Bendik Aagaard Lynghaug 2c545cd1b0 Voice preview stays hidden until there is something to play 2026-08-30 16:06:57 +02:00
Bendik Aagaard Lynghaug 47c894cc1d Release 0.3.11
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m32s
2026-08-30 16:02:42 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 7bc59a65e0 Validate templated actions against the page pattern
resolve_question returns a clone with a concrete id, so is_dynamic()
on it was always false and every templated action failed lint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:02:41 +02:00
Bendik Aagaard Lynghaug 243b6a5704 README: gesture and voice fields
Test / test (push) Successful in 24s
2026-08-30 16:00:25 +02:00
Bendik Aagaard Lynghaug 3a20870762 Release 0.3.10
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m34s
2026-08-30 15:58:05 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 07776be2bb type: voice, Requirement.value, playable resource cards
A voice requirement records in the browser and ships PCM to the relay
as one binary frame (voice.js, same mount/stop contract as gesture);
its value becomes {key, digest, duration_ms}. Requirement.value
presets a field and, on a dynamic page, takes the URL segment - so
value: "{key}" tells the voice field where to record. A url resource
source takes the segment too. Resource items with an https `audio`
field render an <audio> player. The gesture widget reports picks to
the relay for ranking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:58:04 +02:00
Bendik Aagaard Lynghaug 39fdb8e0e9 Release 0.3.9
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 3m8s
2026-08-30 15:38:24 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b5ee79da8e Templated actions; places are pickable in the gesture input
action: /shape/{curve.key} fills placeholders from the submitted
responses and must land on a dynamic page (validated). The gesture
widget now treats relay 'place' (kept) and 'echo' (live presence)
frames as pickable options: picking one re-derives the input's key -
that place's decode becomes the ghost under the stroke and the answer
records {key: picked, own_key, selected_from, selected_distance}, the
labelled pair that later ranks places and calibrates the key. Dedupe
by key; an empty state when nothing is kept at the shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:38:23 +02:00
Bendik Aagaard Lynghaug ff2f94e549 Release 0.3.8
Publish release / publish (push) Successful in 1m35s
Test / test (push) Successful in 25s
2026-08-30 14:44:43 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 be954bc43e Descriptions are inline markdown; Feature.link withdrawn
Alternative and feature descriptions render links, emphasis and code
(pulldown-cmark, html feature only). Block structure flattens to one
paragraph, raw HTML is dropped, link targets are limited to https,
mailto and site-relative paths. A link belongs in the prose, so the
title-link field shipped in 0.3.7 goes before anyone uses it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:44:42 +02:00
44 changed files with 8475 additions and 1235 deletions
+67 -9
View File
@@ -56,10 +56,67 @@ jobs:
- name: Build
run: SITE_NAME=${{ vars.PORTAL_SITE_NAME }} cargo leptos build --release
# cargo-leptos only builds the leptos bin-target ("portal") - the
# question-lint utility binary needs its own plain cargo build.
- name: Build question-lint
run: cargo build --release --bin question_lint --features ssr
# The lint (uhhm/iris) is versioned as portal: this step re-pins
# iris to the tag being released, builds and tests it against it,
# and pushes the matching iris tag, which iris's own CI publishes.
# A portal change to something the lint reads fails the release
# right here. Needs IRIS_PUSH_USER (a variable) and IRIS_PUSH_TOKEN
# (a secret) that may push to uhhm/iris; the workflow's own token
# is scoped to this repo. A re-run of a tag whose iris tag already
# exists just builds that.
- name: Release iris to match
env:
# The shared cargo home is read-only for the runner, and a git
# dependency has to be cloned into one (same as uhhm/iris's CI).
CARGO_HOME: /var/local/cargo-target/iris-cargo-home
RUSTC_WRAPPER: /usr/bin/sccache
IRIS_PUSH_USER: ${{ vars.IRIS_PUSH_USER }}
IRIS_PUSH_TOKEN: ${{ secrets.IRIS_PUSH_TOKEN }}
run: |
set -euo pipefail
tag="${{ github.ref_name }}"
ver="${tag#v}"
host="${{ github.server_url }}"
remote="https://${IRIS_PUSH_USER}:${IRIS_PUSH_TOKEN}@${host#https://}/uhhm/iris.git"
# Re-pin, version, changelog: idempotent, so a retry on a fresh
# clone redoes exactly the same edits.
match() {
sed -i -E "s|^(portal = \{ git = \"[^\"]+\", tag = \")[^\"]+(\")|\1$tag\2|" Cargo.toml
sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"$ver\"/" Cargo.toml
cargo update -q -p portal
awk -v h="## $ver ($(date -u +%F)) - portal $tag" -v l="- Matches portal $tag (released by portal's publish job)." \
'done==0 && /^## /{print h; print ""; print l; print ""; done=1} {print}' CHANGELOG.md > CHANGELOG.new
mv CHANGELOG.new CHANGELOG.md
git config user.name "portal release"
git config user.email "portal@uhhm.no"
git commit -qam "Match portal $tag"
git tag "$tag"
}
# Someone may push to iris main while this job builds; a push
# then fails as non-fast-forward, and the answer is to start
# over on a fresh clone, once.
for attempt in 1 2; do
cd "$GITHUB_WORKSPACE"
rm -rf iris-src
git clone -q "$host/uhhm/iris.git" iris-src
cd iris-src
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
echo "iris $tag already exists, building it"
git checkout -q "$tag"
cargo build --release
cargo test
break
fi
match
cargo build --release
cargo test
if git push -q "$remote" main "$tag"; then
echo "pushed iris $tag"
break
fi
echo "push rejected on attempt $attempt (iris main moved), retrying on a fresh clone"
[ "$attempt" = 2 ] && exit 1
done
- name: Package
run: |
@@ -67,7 +124,7 @@ jobs:
tag="${{ github.ref_name }}"
stage=$(mktemp -d)
cp "$CARGO_TARGET_DIR/release/portal" "$stage/portal"
cp "$CARGO_TARGET_DIR/release/question_lint" "$stage/question_lint"
cp "$CARGO_TARGET_DIR/release/iris" "$stage/iris"
# hash-files = true: leptos resolves hash.txt next to the running
# binary, so it ships beside portal in the release dir.
cp "$CARGO_TARGET_DIR/release/hash.txt" "$stage/hash.txt"
@@ -75,7 +132,7 @@ jobs:
# not affected by CARGO_TARGET_DIR - only the plain `cargo build`
# outputs (release/, front/) move with that override.
cp -r target/site "$stage/site"
tar -C "$stage" -czf "portal-$tag.tar.gz" portal question_lint hash.txt site
tar -C "$stage" -czf "portal-$tag.tar.gz" portal iris hash.txt site
rm -rf "$stage"
# The run's own ephemeral token has write access to this repo -
@@ -88,9 +145,10 @@ jobs:
tag="${{ github.ref_name }}"
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
subject=$(git log -1 --format=%s)
body=$(printf '{"tag_name":"%s","name":"%s"}' \
"$tag" "$tag: $(echo "$subject" | sed 's/"/\\"/g')")
# The release notes are this version's section of CHANGELOG.md.
notes=$(awk -v v="${tag#v}" '/^## /{p = ($2 == v)} p' CHANGELOG.md | tail -n +2)
[ -n "$(printf "%s" "$notes" | tr -d "[:space:]")" ] || { echo "CHANGELOG.md has no section for $tag - write it and re-tag"; exit 1; }
body=$(jq -n --arg tag "$tag" --arg notes "$notes" '{tag_name: $tag, name: $tag, body: $notes}')
id=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$body" "$api/releases" | jq .id) \
|| id=$(curl -sf -H "$auth" "$api/releases/tags/$tag" | jq .id)
+17
View File
@@ -22,5 +22,22 @@ jobs:
steps:
- uses: actions/checkout@v4
# A throwaway JetStream for tests/jetstream.rs. Best effort: if the
# runner cannot use docker the variable stays unset and that one
# test reports itself skipped rather than failing the build.
- name: Start a throwaway JetStream
run: |
docker rm -f portal-test-nats >/dev/null 2>&1 || true
if docker run -d --rm --name portal-test-nats -p 127.0.0.1:14223:4222 nats:2.14.6-alpine -js >/dev/null 2>&1; then
sleep 2
echo "PORTAL_TEST_NATS_URL=nats://127.0.0.1:14223" >> "$GITHUB_ENV"
else
echo "::warning::no docker on this runner - the JetStream integration test will be skipped"
fi
- name: Test
run: cargo test --features ssr
- name: Stop the throwaway JetStream
if: always()
run: docker rm -f portal-test-nats >/dev/null 2>&1 || true
+365
View File
@@ -0,0 +1,365 @@
# 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.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 or `X-Real-IP` and 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_transition` that names no `from` means the bucket's own initial state, not the word `open`. 0.5.0 read the literal, which is only right for a bucket whose records start there: redoal's watchers arrive at `pending` and confirm themselves into `open`, so the rule compiled as `open -> open` and 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's `max_age` collects 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.yml` has been red since 0.3.47 while `publish.yml` stayed green, which is how 0.4.0 shipped past it.
- A file can come back out. `GET /attachment/{bucket}/{record}/{field}` streams what a `type: file` field 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.
- `/upload` asks 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 the `question` and `alternative` the file is being attached to, refuses a page that declares no `type: file` field or an alternative that is closed, and asks `Submit` on 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_transition` takes `from: [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: record` with `of: <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 is `where: { 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 its `event`, 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.to` is held to the same declared edge as a desk button - and the move fires the same mail and publishes the same event, stamped `deadline`.
- 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-busy` for a reader that would otherwise find an empty document.
- Gone: the `gitea_org_repos` resource kind, which no site ever named. `gitea_starred` and `gitea_releases` stay.
## 0.4.0 (2026-09-24)
- A field can be a place: `type: location` puts 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 to `event:` - 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 the `MAPBOX_TOKEN` never 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.received` and 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/search` on `mail`, 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` (was `WORMHOLE`); 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: submitter` or one address; `{field}`, `{site}`, `{chain}`, `{email}` in the templates), and an invite mails its one-time link built in; `site.yaml` gains `mail: { from, reply_to, invite }`. Portal renders and publishes on `portal.mail.send` in the `WORMHOLE` stream; uhhm/gdo delivers. The lint requires a required `email` field 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 `null` rather 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 --access` prints 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 on `portal.access.decided`, and `portal.access.<site>.may` answers 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 in `portal_invites` and on `portal.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_USER` variable and `IRIS_PUSH_TOKEN` secret); 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_lint` becomes `iris check`, `needs_replay` becomes `iris replay`; the tarball ships `iris` (pinned in publish.yml) instead of `question_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)
- `content` split into `gitea`, `validate`, `markdown`, `handlers`; every path still `content::x`
- This changelog, and the table of what each site runs
- Business needs as a checkable spec: needs.yaml + question_lint pass
- needs: `success` states 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 <img>
- 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 | `app@tomtervel-portal` | v0.5.2 |
Generated
+680 -3
View File
@@ -44,6 +44,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "ar_archive_writer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
dependencies = [
"object",
]
[[package]]
name = "arc-swap"
version = "1.9.2"
@@ -53,6 +62,21 @@ dependencies = [
"rustversion",
]
[[package]]
name = "arrayvec"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "ascii-canvas"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891"
dependencies = [
"term",
]
[[package]]
name = "async-lock"
version = "3.4.2"
@@ -108,9 +132,9 @@ checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"
[[package]]
name = "async-trait"
version = "0.1.91"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
@@ -661,6 +685,16 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "borsh"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
dependencies = [
"bytes",
"cfg_aliases",
]
[[package]]
name = "bs58"
version = "0.5.1"
@@ -724,6 +758,70 @@ dependencies = [
"shlex",
]
[[package]]
name = "cedar-policy"
version = "4.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e4f5e46c425491b7133eecb3030d82d27301b84bfa08533c59a6902cca7eb80"
dependencies = [
"cedar-policy-core",
"cedar-policy-formatter",
"itertools 0.15.0",
"linked-hash-map",
"linked_hash_set",
"miette",
"ref-cast",
"semver",
"serde",
"serde_json",
"serde_with",
"smol_str",
"thiserror 2.0.19",
]
[[package]]
name = "cedar-policy-core"
version = "4.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a30d11033d59b262bbb380d8bb5dc5c3dd2dd2281675c558ef4fd8a161ce669"
dependencies = [
"chrono",
"educe",
"either",
"itertools 0.15.0",
"lalrpop",
"lalrpop-util",
"linked-hash-map",
"linked_hash_set",
"miette",
"nonempty 0.12.0",
"ref-cast",
"regex",
"rustc-literal-escaper",
"serde",
"serde_json",
"serde_with",
"smol_str",
"stacker",
"thiserror 2.0.19",
"unicode-security",
]
[[package]]
name = "cedar-policy-formatter"
version = "4.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a2c8dc671e62597c85caa11694365b76050abcd9f2177f0520ccac0580f9a41"
dependencies = [
"cedar-policy-core",
"itertools 0.15.0",
"logos",
"miette",
"pretty",
"regex",
"smol_str",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -962,6 +1060,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
@@ -1063,6 +1167,20 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -1229,6 +1347,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "educe"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e451fac8dd8dece16234604bf1efce6e90fddd8ab6ad4d66eec0eca5160959dd"
dependencies = [
"enum-ordinalize",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "either"
version = "1.17.0"
@@ -1266,6 +1396,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ena"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
dependencies = [
"log",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1275,6 +1414,26 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "enum-ordinalize"
version = "4.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677"
dependencies = [
"enum-ordinalize-derive",
]
[[package]]
name = "enum-ordinalize-derive"
version = "4.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1345,6 +1504,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1372,6 +1537,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "forwarded-header-value"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
dependencies = [
"nonempty 0.7.0",
"thiserror 1.0.69",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -1449,6 +1624,12 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
version = "0.3.33"
@@ -1497,9 +1678,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1550,6 +1733,29 @@ dependencies = [
"web-sys",
]
[[package]]
name = "governor"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"dashmap",
"futures-sink",
"futures-timer",
"futures-util",
"getrandom 0.3.4",
"hashbrown 0.16.1",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.9.5",
"smallvec",
"spinning_top",
"web-time",
]
[[package]]
name = "group"
version = "0.13.0"
@@ -1611,6 +1817,12 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1848,6 +2060,19 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper 1.11.0",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2066,6 +2291,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2193,6 +2427,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keccak"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "konst"
version = "0.2.20"
@@ -2208,6 +2451,38 @@ version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
[[package]]
name = "lalrpop"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501"
dependencies = [
"ascii-canvas",
"bit-set",
"ena",
"itertools 0.14.0",
"lalrpop-util",
"petgraph",
"pico-args",
"regex",
"regex-syntax",
"sha3",
"string_cache",
"term",
"unicode-xid",
"walkdir",
]
[[package]]
name = "lalrpop-util"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733"
dependencies = [
"regex-automata",
"rustversion",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2451,6 +2726,24 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
dependencies = [
"serde",
]
[[package]]
name = "linked_hash_set"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8"
dependencies = [
"linked-hash-map",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2479,6 +2772,38 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "logos"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f"
dependencies = [
"logos-derive",
]
[[package]]
name = "logos-codegen"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970"
dependencies = [
"fnv",
"proc-macro2",
"quote",
"regex-automata",
"regex-syntax",
"syn 2.0.119",
]
[[package]]
name = "logos-derive"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31"
dependencies = [
"logos-codegen",
]
[[package]]
name = "lru"
version = "0.16.4"
@@ -2548,6 +2873,29 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"cfg-if",
"miette-derive",
"serde",
"unicode-width 0.1.14",
]
[[package]]
name = "miette-derive"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -2592,6 +2940,12 @@ dependencies = [
"version_check",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "next_tuple"
version = "0.1.0"
@@ -2613,6 +2967,27 @@ dependencies = [
"signatory",
]
[[package]]
name = "nonempty"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
[[package]]
name = "nonempty"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"
dependencies = [
"serde",
]
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2712,6 +3087,15 @@ dependencies = [
"url",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "oco_ref"
version = "0.2.1"
@@ -2872,6 +3256,31 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap 2.14.0",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2948,14 +3357,16 @@ dependencies = [
[[package]]
name = "portal"
version = "0.3.7"
version = "0.5.3"
dependencies = [
"anyhow",
"arc-swap",
"async-nats",
"async-trait",
"aws-sdk-s3",
"axum",
"base64 0.22.1",
"cedar-policy",
"chrono",
"console_error_panic_hook",
"dotenvy",
@@ -2970,6 +3381,7 @@ dependencies = [
"leptos_router",
"openidconnect",
"proptest",
"pulldown-cmark",
"serde",
"serde_json",
"serde_yaml",
@@ -2978,6 +3390,7 @@ dependencies = [
"tower",
"tower-http",
"tower-sessions",
"tower_governor",
"tracing",
"tracing-subscriber",
"url",
@@ -3011,6 +3424,23 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "pretty"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156"
dependencies = [
"arrayvec",
"typed-arena",
"unicode-width 0.2.2",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -3104,6 +3534,49 @@ dependencies = [
"unarray",
]
[[package]]
name = "psm"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags 2.13.1",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]]
name = "quick-error"
version = "1.2.3"
@@ -3303,6 +3776,15 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.13.1",
]
[[package]]
name = "reactive_graph"
version = "0.2.14"
@@ -3530,6 +4012,12 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc-literal-escaper"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe6f213fb658c8fb95baabd5420393438cf5a98d707f5dd701d9197c705f71e"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3855,6 +4343,7 @@ version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"indexmap 2.14.0",
"itoa",
"memchr",
"serde",
@@ -4088,6 +4577,16 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "sha3"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest 0.10.7",
"keccak",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -4135,6 +4634,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
version = "0.4.12"
@@ -4156,6 +4661,16 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "smol_str"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
dependencies = [
"borsh",
"serde_core",
]
[[package]]
name = "socket2"
version = "0.5.10"
@@ -4188,6 +4703,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
@@ -4204,6 +4728,31 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
]
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -4315,6 +4864,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "term"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4475,6 +5033,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
@@ -4553,6 +5122,35 @@ dependencies = [
"winnow",
]
[[package]]
name = "tonic"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"h2 0.4.15",
"http 1.4.2",
"http-body 1.1.0",
"http-body-util",
"hyper 1.11.0",
"hyper-timeout",
"hyper-util",
"percent-encoding",
"pin-project",
"socket2 0.6.5",
"sync_wrapper",
"tokio",
"tokio-stream",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -4561,9 +5159,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"indexmap 2.14.0",
"pin-project-lite",
"slab",
"sync_wrapper",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
@@ -4675,6 +5276,23 @@ dependencies = [
"tower-sessions-core",
]
[[package]]
name = "tower_governor"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
dependencies = [
"axum",
"forwarded-header-value",
"governor",
"http 1.4.2",
"pin-project",
"thiserror 2.0.19",
"tonic",
"tower",
"tracing",
]
[[package]]
name = "tracing"
version = "0.1.44"
@@ -4819,12 +5437,49 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-script"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-security"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50"
dependencies = [
"unicode-normalization",
"unicode-script",
]
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
@@ -5060,6 +5715,22 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -5069,6 +5740,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
+12 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.3.7"
version = "0.5.3"
edition = "2021"
[lib]
@@ -12,6 +12,9 @@ leptos_meta = { version = "0.8" }
leptos_router = { version = "0.8" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Inline markdown for content descriptions (links, emphasis, code) -
# runs on both server and client renders, so not feature-gated.
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
# --- server only ---
leptos_axum = { version = "0.8", optional = true }
@@ -35,6 +38,7 @@ uuid = { version = "1", features = ["v4", "serde"], optional = true }
dotenvy = { version = "0.15", optional = true }
anyhow = { version = "1", optional = true }
futures = { version = "0.3", optional = true }
cedar-policy = { version = "4", optional = true }
tracing = { version = "0.1", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
# jq-filter evaluation for ResourceSource's optional `jq` field
@@ -59,6 +63,8 @@ web-sys = { version = "0.3", features = [
"Response",
"Window",
], optional = true }
tower_governor = { version = "0.8.0", optional = true }
async-trait = { version = "0.1.92", optional = true }
[features]
default = []
@@ -77,6 +83,8 @@ ssr = [
"dep:leptos_axum",
"dep:axum",
"dep:aws-sdk-s3",
"dep:tower_governor",
"dep:async-trait",
"dep:tokio",
"dep:tower",
"dep:tower-http",
@@ -93,6 +101,7 @@ ssr = [
"dep:dotenvy",
"dep:anyhow",
"dep:futures",
"dep:cedar-policy",
"dep:tracing",
"dep:tracing-subscriber",
"dep:jaq-core",
@@ -103,9 +112,8 @@ ssr = [
[dev-dependencies]
proptest = "1"
[[bin]]
name = "question_lint"
path = "src/bin/question_lint.rs"
[[test]]
name = "jetstream"
required-features = ["ssr"]
[profile.wasm-release]
+31 -127
View File
@@ -1,147 +1,51 @@
# portal
A content-driven question engine: one Rust binary that turns a Git
repo of YAML into a live site — pages, forms, review desks, and state
repo of YAML into a live site — pages, forms, review desks and state
machines, with a durable event-sourced record of every answer
underneath. The engine special-cases nothing: everything a site
serves is declared in its content repo, and the same build serves any
number of sites.
underneath.
Point an instance at a content repo and that repo *is* the site:
pages, copy, state graphs, branding, and even the landing hero
(`site.yaml`: title, wordmark, and `hero: {kind: module, module:
hero.js}` — a JavaScript module the content repo ships, served
same-origin at `/site/<path>`, that portal mounts at parse time and
stops on navigation; the YES canvas and redoal's sine swings are both
content, not engine). Content
pushes hot-reload every running instance; instances differ only by
env vars. [uhhm.no](https://uhhm.no)
([uhhm/questions](https://project.uhhm.no/uhhm/questions)) and
[redoal.com](https://redoal.com)
([redoal/questions](https://project.uhhm.no/redoal/questions)) are
two faces of the same binary, deployed from the same release
artifact.
The engine special-cases nothing. Everything a site serves is declared
in its content repo, content pushes hot-reload every running instance,
and the same build serves any number of sites: instances differ only
by environment variables. [uhhm.no](https://uhhm.no) and
[redoal.com](https://redoal.com) are two faces of the same binary from
the same release artifact.
It composes with the surrounding stack rather than bundling it: Gitea
hosts and serves the content, NATS JetStream stores events and
projections, Kanidm provides identity, and anything downstream
(automations, newsletters, onboarding) subscribes to the answer
stream.
It composes with the surrounding stack rather than bundling it. Gitea
hosts the content, NATS JetStream stores the events and projections,
Kanidm provides identity, and anything downstream — automations,
newsletters, onboarding — subscribes to the answer stream.
## What the engine provides
## Documentation
- **Content-driven pages** (`src/content.rs`, `src/app.rs`): YAML
loaded from Gitea at boot and hot-swapped on a NATS reload signal;
a bad push keeps the last-good content serving. The `questions/`
tree IS the router — file paths become URLs, `_section.yaml`
applies criteria to a whole directory, `[name].yaml` pages serve
any `/dir/<value>` with the segment fed into resource keys, and
`requires_chain` gates a page on verifiable answer provenance next
to `qualifies`' Kanidm-group identity gate (see
`docs/design/filesystem-routes.md`).
- **Announced pages** (`src/announce.rs`): a question with an
`event: {starts, duration, place}` window is announced in the
header (not the footer nav) while the window is open, becomes a
followup when it closes, and its record in the runtime-owned
`portal_events` bucket moves to `awaiting_summary` - a "post what
happened" task any review desk can read, published on NATS like a
decision. A one-minute sweeper keeps it all idempotent.
- **State machines as content** (`src/aggregates/`): `aggregates.yaml`
declares each bucket's states and legal transitions; the engine
replays a record's event history and refuses undeclared moves, with
optimistic concurrency (CAS on the event log's sequence) against
racing decisions. An empty history reseeds from the KV projection,
so wiping the event stream strands nothing.
- **Durable events + projections** (`src/events/`, `src/answers.rs`):
every submission and decision appends to a JetStream event log and
projects into a NATS KV bucket pages read back; every one also
publishes on `portal.answers.submitted` for automations (n8n) to
react to.
- **Answer chains** (`src/chain.rs`): each submission hashes its
parent(s), so a visitor's path through the questions is a verifiable
lineage; `?chain=` links carry it, and self-service transitions
(unsubscribe) authorize by holding one.
- **Live resources** (`src/resource.rs`): content can pull a KV
bucket, Gitea starred/org repos/releases, or any public JSON URL
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
- **Input kinds as content**: a requirement's `type:` picks the
widget — plain fields, a rich-text editor, or a `gesture` drawing
canvas that can connect to a redoal relay so similar strokes echo
between visitors live. Submitted values are arbitrary JSON; the
engine records what the widget produced.
- **Review desks**: any Kv resource with `transitions` renders rows
with per-state action buttons and one shared confirm per
alternative — owners walk records through their graphs without
bespoke UI per bucket.
Everything is under [`docs/`](docs/README.md):
## Binaries
- `portal` — the server (Leptos SSR + hydrate, Axum underneath).
- `question_lint` — headless content validation, shipped inside every
release artifact so each content repo's CI lints with the exact
portal version its instance runs; also works offline:
`question_lint --path <dir>`.
- [Architecture](docs/architecture.md) — what the engine is made of.
- [Content reference](docs/content-reference.md) — every key a content
repo may write.
- [State machines](docs/state-machines.md) — `aggregates.yaml`.
- [Access](docs/access.md) — the policy compiled from content.
- [Operations](docs/operations.md) — releasing, deploying, adding a site.
## Development
```sh
cargo leptos build # full app (server + wasm)
cargo test --features ssr # engine tests
cargo build --features ssr --bin question_lint
cargo leptos build # server + wasm
cargo test --features ssr # tests/jetstream.rs needs PORTAL_TEST_NATS_URL
```
Runtime configuration is env vars (see `src/main.rs`, and each
content repo's `.gitea/workflows/deploy.yml` for the values in
production): `NATS_URL`, `CONTENT_REPO`/`CONTENT_BRANCH`, Kanidm OIDC
(`KANIDM_URL`, `OAUTH2_CLIENT_*`), optional `GARAGE_*` for uploads,
`GITEA_API_TOKEN` for authenticated resource pulls,
`AUTOMATION_READ_TOKEN` for the automation KV read endpoint.
## Release and deploy
Portal doesn't deploy itself — it publishes versions, and each site
decides when to take one. The whole day-to-day surface is two
commands:
**Cut a release** (here):
Content is linted by [iris](https://project.uhhm.no/uhhm/iris), which
depends on this crate and carries the same version, so a content repo
can check itself against the exact portal its instance runs:
```sh
cargo release patch # or minor / major
iris check --path questions
```
That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v<version>.tar.gz` (`portal` + `question_lint` +
`hash.txt` + `site/`) to the Gitea release. Pkg files are
content-hashed (`hash-files = true`; instances run with
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
stale cached bundle with new wasm. Plain pushes to `main` only run the
tests (`test.yml`) — nothing reaches production from this repo.
## Releasing
**Roll it out** (in a content repo): edit one line in that repo's
`.gitea/workflows/deploy.yml` —
```yaml
env:
PORTAL_RELEASE: v0.1.1 # <- bump, commit, push
```
Its CI downloads the pinned artifact, ships
`/srv/app/<instance>/releases/<tag>`, flips `current`, rewrites the
instance env from that repo's own Actions variables/secrets, restarts
`app@<instance>`, and refreshes the Caddy route. Every rollout is a
commit, so reverting a bad version is `git revert` + push. Sites
upgrade independently: uhhm.no (uhhm/questions → `app@uhhm-portal`,
:3010) and redoal.com (redoal/questions → `app@redoal-portal`,
:3020) can pin different versions.
Content changes never come through any of this — they hot-reload
live from the content repos over NATS.
**Add a site**: new content repo with a copy of an existing
`deploy.yml` (change `INSTANCE`, port, domains), Actions variables
(`KANIDM_URL`, `OAUTH2_CLIENT_ID`, `PUBLIC_URL`, `SITE_NAME`) and
secrets (`NATS_URL`, `OAUTH2_CLIENT_SECRET`,
`PORTAL_GITEA_API_TOKEN` — Gitea reserves the `GITEA_` prefix for
secret names), a Kanidm OAuth2 client, and DNS. `site.yaml` in the
content repo handles all branding; no portal changes needed.
`cargo release patch` tags and pushes; CI builds the tarball and
attaches it to the Gitea release. A site takes it by bumping
`PORTAL_RELEASE` in its own content repo. See
[operations](docs/operations.md).
+22
View File
@@ -0,0 +1,22 @@
# portal documentation
Portal turns a Git repo of YAML into a running site. Almost everything
here is about that YAML: what a content repo may say, and what the
engine does with it.
| | |
|---|---|
| [Architecture](architecture.md) | What the engine is made of and where each piece lives in the source. |
| [Content reference](content-reference.md) | Every key a content repo may write, and what it does. |
| [State machines](state-machines.md) | `aggregates.yaml`: stages, who moves a case, mail, deadlines, memberships. |
| [Access](access.md) | How the Cedar policy is compiled from content, and who may ask it what. |
| [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. |
| [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. |
Two repos in the same family carry their own docs:
- [uhhm/iris](https://project.uhhm.no/uhhm/iris) — the lint. Versioned
as portal and released by portal's publish job; it is the thing a
content repo's CI runs before anything reaches a site.
- [uhhm/gdo](https://project.uhhm.no/uhhm/gdo) — the mailer. Delivers
what content asked to be sent, and carries replies back.
+55
View File
@@ -0,0 +1,55 @@
# Access
Who may do what to what is compiled from content on every load into
one Cedar policy set, and every server entry point asks that one
policy. Identity stays Kanidm's; portal only decides.
## What content compiles to
| content | rule |
|---|---|
| a page with no `qualifies` | anyone may open and submit |
| `qualifies: case_officers` | that group may open and submit |
| `requires_chain: /start` | the visitor's verified answer lineage must reach it |
| a resource with `public: true` | anyone may read it |
| a resource with `requires_group: g` | that group may read it |
| a desk resource's `transitions` | that group may make those moves |
| an alternative's `self_transition` | the submitter may make it, from each state in `from`, with their link and their matching email |
| an alternative's `invite: {group}` | the page's own group may invite into that group |
Mutations always need a group, whatever `public` says. A resource with
neither `public` nor `requires_group` is unreachable rather than open:
the policy fails closed.
## Reading the policy
```sh
iris check --path questions --access
```
prints every rule the content compiled to, as a table of who may do
what from where to where. It is the fastest way to find a rule a
content repo did not mean to write — a refusal available on the day a
case arrives, a board that can decide a case it never received.
## Asking it
Every decision carries the rule ids that granted it. Refusals and
every mutation are published on `portal.access.decided`.
Other applications ask the same question over NATS request-reply on
`portal.access.<site>.may`, with the same principal, action, resource
and context the server uses internally — so an automation acting as a
group gets exactly the answer a person in that group would.
## The submitter
A person who sent a form has no account. Their credential is the link:
an unguessable chain hash, plus the email they gave, which the policy
compares against the one stored on the record.
What they may do, and from where, is `self_transition.from`. Its
default is the initial state alone. A refusal reads "not found" while
the record is somewhere the move could have been made from — so the
endpoint cannot be used to discover which records or addresses exist —
and "already processed" once the case has moved past it.
+92
View File
@@ -0,0 +1,92 @@
# Architecture
One Rust binary (Leptos SSR + hydrate, Axum underneath) serving any
number of sites. The engine special-cases nothing: every page, form,
desk and state graph a site serves is declared in its content repo,
and instances differ only by environment variables.
It composes with the surrounding stack rather than bundling it. Gitea
hosts the content, NATS JetStream stores the events and projections,
Kanidm provides identity, and anything downstream subscribes to the
answer stream.
## The pieces
**Content** (`src/content/`) — YAML loaded from Gitea at boot and
hot-swapped on a NATS reload signal. A bad push keeps the last-good
content serving. The `questions/` tree is the router: file paths
become URLs, `_section.yaml` applies to a whole directory, and
`[name].yaml` serves any `/dir/<value>` with the segment fed into the
page's resource keys. See [filesystem routes](design/filesystem-routes.md).
**The lint** (`src/content/validate.rs`) — every rule a content repo
is held to. It runs at boot, on every hot reload, and in a content
repo's CI through [iris](https://project.uhhm.no/uhhm/iris), which
depends on this crate and so applies exactly the version the instance
runs.
**State machines** (`src/aggregates/`) — `aggregates.yaml` declares
each bucket's states and legal moves. A record's state is replayed
from its own event history, undeclared moves are refused, and racing
decisions are settled by CAS on the log's sequence. An empty history
reseeds from the KV projection, so wiping the event stream strands
nothing. See [state machines](state-machines.md).
**Events and projections** (`src/events/`, `src/answers.rs`) — every
submission and decision appends to a JetStream log and projects into
a NATS KV bucket that pages read back, and publishes on
`portal.answers.submitted` for automations to react to.
**Answer chains** (`src/chain.rs`) — each submission hashes its
parents, so a visitor's path through the questions is a verifiable
lineage. `?chain=` links carry it; a `followup` page is offered only
once the chain has earned it, and a page behind an answer somebody has
not given is never offered.
**Access** (`src/access.rs`) — who may do what to what, compiled from
content on every load into one Cedar policy set that every server
entry point asks. See [access](access.md).
**People** (`src/people.rs`) — onboarding as a form. An alternative
with `invite: {group}` creates the person in Kanidm, records their
email, adds them to the group and mints a one-time credential link. A
state may also grant a group on its own (`grants:`), so an approval
that means membership needs no second step.
**Mail** (`src/mail.rs`) — a state may declare the mail its arrival
sends. Portal renders it and publishes on `portal.mail.send`;
[gdo](https://project.uhhm.no/uhhm/gdo) delivers it and carries
replies back on `portal.mail.received`, where they become notes beside
the record. A note never moves a case.
**Sweepers** (`src/announce.rs`, `src/deadlines.rs`) — the two things
that move without a person. An announced page's window closes and its
record becomes a "post what happened" task; a state with a deadline
waits out its days and then makes the move itself. Both go through the
same transition path as a desk button and publish the same event.
**Resources** (`src/resource.rs`) — content can read a KV bucket,
Gitea starred repos, org repos or releases, or any public JSON URL
(SSRF fail-closed), reshaped by a content-declared `jq` filter. What a
resource reads is never a client-supplied parameter: the client names
a page and a feature, never a bucket or URL.
**Maps** (`src/maps.rs`) — MapLibre is vendored and every tile, style
and still image is proxied through portal, so a page with a map makes
no third-party request and needs no consent banner.
**Desks** — any Kv resource with `transitions` renders rows with
per-state buttons and one shared confirm. Owners walk records through
their graphs with no bespoke UI per bucket.
## Binaries
- `portal` — the server.
- `iris` — the lint, from its own repo, shipped in every release
tarball so a content repo's CI can lint with the exact version its
instance runs.
**Sessions** (`src/sessions.rs`) — in the same JetStream KV as
everything else, so a restart does not sign anybody out. The bucket
carries a `max_age` so NATS collects what nobody comes back for, and
each record's own `expiry_date` is what decides on the way out.
+232
View File
@@ -0,0 +1,232 @@
# Content reference
A content repo is the site. This is everything it may say.
```
site.yaml branding, language, mail
aggregates.yaml the state machine of every bucket
needs.yaml what the site is for (read by iris, never by portal)
questions/
index.yaml the front page, served at /
about.yaml served at /about
review/
_section.yaml applies to everything under /review
index.yaml served at /review
[record].yaml serves /review/<anything>
```
Nothing here is required except a `questions/` tree with something in
it. Portal's lint refuses content it cannot serve honestly, at boot,
on every hot reload, and in CI through `iris check`.
## site.yaml
```yaml
title: Vestmark kommune # the browser title and the header
wordmark: /site/logo.svg # an image instead of the title
wordmark_invert: true # flip it in dark mode
wordmark_height: 2.4 # rem
lang: no # the interface language: en or no
stylesheet: /site/theme.css # served same-origin from the repo
favicon: /site/icon.svg
hero: { kind: module, module: hero.js } # or { kind: plain }
mail:
from: Vestmark kommune <post@vestmark.example>
reply_to: post@vestmark.example
invite: { subject: "...", body: "..." } # rewords the built-in invite mail
```
`mail.from` is required as soon as any state declares a mail.
## A question
One YAML file is one page.
```yaml
id: /apply # defaults to the file's path
name: Apply to build
description: What we need from you.
qualifies: case_officers # a Kanidm group may open this page
requires_chain: /start # or: an answer they must already have given
followup: true # keep it out of the nav until earned
responsible: { name: Solveig, email: post@vestmark.example }
event: # this page announces an occasion
starts: 2026-10-14T18:00
duration: 2h
place: The town hall
place: # this page is about somewhere
lat: 59.8938
lon: 11.0332
zoom: 15
name: Tomter
alternatives: [ ... ]
```
`qualifies`, `requires_chain` and `responsible` are inherited from the
nearest `_section.yaml`. `event` makes a page an occasion with a time;
`place` makes it a place, drawing a still map under the question and
opening any `type: location` field on the page there rather than on
the whole world.
## An alternative
One choice. Never two actions behind one heading: if two things can be
done, they are two alternatives.
```yaml
alternatives:
- name: Send the drawings
description: We answer within twelve weeks.
action: /sent # the page to go to; /shape/{field} templates
record_as: applications # the bucket the answer lands in
disabled: false
consequence: [What happens next]
encouragements: [It takes two minutes]
images: [/site/drawing.png]
features: [ ... ]
```
Three things an alternative may do instead of, or beside, recording an
answer:
```yaml
self_transition: # the person moves their own record
bucket: applications
to: complete
from: [open, incomplete] # defaults to the initial state alone
label: Send them
invite: { group: case_officers } # onboard someone into Kanidm
```
`self_transition.from` is what keeps a person in dialogue with their
case: an applicant written to for a missing drawing answers from
`incomplete`, and may withdraw from wherever the case has got to.
Omitted, it is the initial state alone, which is what a one-shot
unsubscribe link means. Each state listed becomes its own access rule,
and the lint refuses a `from -> to` the state machine does not declare.
An `invite` needs a required `name` and `email` on the same
alternative, and the inviting group must be allowed to invite that
group by the policy.
## A feature
What an alternative offers, explained. A feature that is only layout
is a feature wasted: each one says something true about the choice.
```yaml
features:
- name: What you need
description: The case number from the letter.
icon: document
color: "#8ec2c0"
requirements: [ ... ] # form fields
resource: { ... } # live data
```
## A requirement (a form field)
```yaml
requirements:
- name: email # snake_case; this is the stored key
label: Your email
placeholder: you@example.no
type: email
optional: false
value: "{key}" # a preset; a dynamic page's segment fills it
```
`type` is one of:
| type | what it is |
|---|---|
| `text` | the default |
| `email`, `tel`, `date`, `number`, … | anything else is passed straight through as the HTML input type |
| `textarea` | several lines |
| `prosekit` | a rich-text editor |
| `select` | one choice, or `multiple: true` for any number |
| `file` | an upload, with `accept:` and `multiple:` |
| `location` | a pin on a map, stored as `{lat, lon, zoom}` |
| `record` | the record this one belongs to |
| `gesture`, `voice` | a drawn stroke or a recording, through a redoal relay |
| `hidden` | a carrier value, no label row |
| `module` | a widget the content repo ships |
A `select` takes either inline `options: [...]` or a `resource:` to
draw them from, with `id_field:` naming each item's stable id.
A field may load its value from a resource when a sibling changes:
```yaml
- name: contents
type: textarea
bind:
field: path # the sibling to watch
resource: { source: { kind: url, url: "https://git.example/raw/{path}" } }
```
### type: record
How one case belongs to another — an objection to the application it
objects to, an appeal to the decision it appeals.
```yaml
- name: case
type: record
of: applications # the bucket the other record is in
value: "{key}" # arrives on the link, on a dynamic page
```
Nobody types one of these: the stored value is the parent's record id,
which is a chain hash. It arrives the way it was given out, so the
field carries rather than asks, and renders hidden. A submission
naming a record that is not there is refused, so a reference in a
stored record is one that resolved at least once.
The other half is a listing that asks for the children:
```yaml
resource:
source: { kind: kv, bucket: objections }
requires_group: case_officers
where: { field: case, equals: "{key}" }
```
## A resource
Live data, declared by content and read by the server. What a resource
reads is never a client-supplied parameter.
```yaml
resource:
source: { kind: kv, bucket: applications }
key: abc123 # one record instead of the list
public: true # or requires_group: case_officers
states: [approved, refused] # only these; empty means all
where: { field: case, equals: "{key}" }
jq: ".[] | {name, url: .html_url}"
empty: Nothing decided yet.
transitions: # the desk's buttons
- { from: complete, to: on_hearing, label: Send it out }
```
`source.kind` is one of `kv`, `gitea_starred`,
`gitea_releases`, `url`.
A spec with neither `public: true` nor `requires_group` is
unreachable: reads fail closed, and mutations always need a group
whatever `public` says.
`states` and `where` narrow a listing. They matter most for a register
that is public by law — public about what was decided, not about what
was withdrawn — while the desk working the same bucket is a different
spec and still sees all of it. A record that has no such field never
matches: "everything that did not say" is not something a page can ask
for by accident.
## needs.yaml
What the site is for, as a checkable spec: who wants what, which
bucket the answer must land in, which group carries it to a finished
state. The runtime never reads it. `iris check` proves the site meets
it, and `iris replay` runs cases through the real engine.
+152
View File
@@ -0,0 +1,152 @@
# Operations
Portal does not deploy itself. It publishes versions; each site
decides when to take one.
## Cut a release
```sh
cargo release patch # or minor / major
```
That bumps `Cargo.toml`, tags `v<version>` and pushes. CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v<version>.tar.gz` (`portal`, `iris`, `hash.txt`,
`site/`) to the Gitea release. Plain pushes to `main` only run the
tests: nothing reaches production from this repo.
The same job re-pins, tests and tags [iris](https://project.uhhm.no/uhhm/iris)
to match, so a portal change to anything the lint reads fails the
release rather than the next content push.
Before cutting, move the `Unreleased` lines in `CHANGELOG.md` under a
heading for the new version. That section becomes the release notes,
and the table at the end of the file records which version each site
runs.
Pkg files are content-hashed (`hash-files = true`, instances run with
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
stale cached bundle with new wasm.
## Roll it out
In the content repo, one line of `.gitea/workflows/deploy.yml`:
```yaml
env:
PORTAL_RELEASE: v0.4.0
```
Its CI downloads the pinned artifact, ships
`/srv/app/<instance>/releases/<tag>`, flips `current`, rewrites the
instance env from that repo's own Actions variables and secrets,
restarts `app@<instance>` and refreshes the Caddy route. Every rollout
is a commit, so reverting a bad version is `git revert` and a push.
Sites upgrade independently and may pin different versions.
Content changes never come through any of this: they hot-reload live
from the content repos over NATS.
## Add a site
A new content repo with a copy of an existing `deploy.yml` (change
`INSTANCE`, the port and the domains), Actions variables
(`KANIDM_URL`, `OAUTH2_CLIENT_ID`, `PUBLIC_URL`, `SITE_NAME`) and
secrets (`NATS_URL`, `OAUTH2_CLIENT_SECRET`, `PORTAL_GITEA_API_TOKEN`
— Gitea reserves the `GITEA_` prefix for secret names), a Kanidm
OAuth2 client, and DNS. `site.yaml` handles all branding; no portal
change is needed.
## Environment
Runtime configuration is environment variables; `src/main.rs` is the
authority and each content repo's `deploy.yml` holds the production
values.
| | |
|---|---|
| `NATS_URL` | events, projections, sessions, content reload, access questions |
| `CONTENT_REPO`, `CONTENT_BRANCH` | the repo that is the site |
| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in |
| `KANIDM_API_TOKEN` | invites and `grants:`; unset, neither happens |
| `PUBLIC_URL`, `SITE_NAME` | what the site calls itself in links and mail |
| `GITEA_API_TOKEN` | authenticated resource pulls |
| `AUTOMATION_READ_TOKEN` | the automation KV read endpoint |
| `GARAGE_*` | uploads |
| `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off |
| `GARAGE_*` (4) | attachments; see below. Unset, upload fields fail closed |
| `PORTAL_RATE_BURST`, `PORTAL_RATE_SECONDS` | the rate limit; see below |
Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a
separate unit on the same host reading the same NATS.
## Rate limiting
Every request passes a per-caller-IP limit before a session is loaded
or a handler is entered: a burst of 30, refilled one every two
seconds. A visitor filling in a form never reaches it (a page load
fetches several resources at once); a script does.
| | |
|---|---|
| `PORTAL_RATE_BURST` | how many requests at once, default 30 |
| `PORTAL_RATE_SECONDS` | seconds per refilled request, default 2 |
Either set to `0` turns it off, for an instance behind something that
already does this. The caller is read from `X-Forwarded-For` or
`X-Real-IP`, falling back to the socket — all of these instances are
behind Caddy.
This is a fence, not an accounting system. What keeps uploads honest
is the policy check on the route itself.
## Attachments
A `type: file` field stores an object key in the record. The file
comes back out at:
GET /attachment/{bucket}/{record}/{field}
which reads the key out of that record rather than taking one from the
caller, so a file can only be fetched through a record somebody is
allowed to see, and a leaked key is a link to nothing. Reading it
needs the same policy permission as reading the bucket on a desk.
Uploads need a bucket and a key in the Garage on the host:
```sh
garage bucket create <site>-attachments
garage key create <site>-portal-uploads
garage bucket allow --read --write <site>-attachments --key <site>-portal-uploads
```
then `GARAGE_S3_ENDPOINT`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY` and
`GARAGE_UPLOADS_BUCKET` in that instance's env. Unset, an upload field
fails closed rather than appearing to work and storing nowhere.
## Who deploys what
Both hosts deploy from CI now, and a rollout is the same commit either
way: bump `PORTAL_RELEASE` in the site's own content repo.
| | runner | how it is allowed to |
|---|---|---|
| ergo | `runs-on: bare` | the runner is privileged and writes `/srv/app` and `/etc/app` itself |
| kasse | `runs-on: fish` | the runner is not privileged. It may run one root script, `/usr/local/bin/deploy-portal-instance`, which checks the instance against a fixed list and the tag against a release-tag shape before touching anything |
The kasse grant is a single sudoers line
(`/etc/sudoers.d/30-portal-deploy`). It exists so a workflow can pick
which of three known instances gets which published portal tag, and
nothing else.
Both paths health-check the instance afterwards **without** a forwarded
header. A check that sends the header Caddy would send cannot tell you
the site is broken for everything that is not Caddy, which is how
v0.5.1 reached a host.
`IRIS_RELEASE` in a repo's lint workflow should equal its
`PORTAL_RELEASE`. Bump it only once iris's own publish job has attached
the binary for that tag - portal's release tags iris, but iris builds
and uploads on its own run, and a pin bumped in between fails the lint
on a download that is not there yet.
+151
View File
@@ -0,0 +1,151 @@
# State machines
`aggregates.yaml` declares what a bucket's records may be and how they
move. It is the one file that decides what is possible; the pages only
decide who presses what.
```yaml
aggregates:
- bucket: applications
initial: open
states:
open: { event: received }
incomplete: { event: incomplete }
complete: { event: complete }
on_hearing: { event: on_hearing }
recommended: { event: recommended }
approved: { event: approved }
refused: { event: refused }
withdrawn: { event: withdrawn }
transitions:
open: [incomplete, complete, withdrawn]
incomplete: [complete, withdrawn]
complete: [incomplete, on_hearing, refused, withdrawn]
on_hearing: [incomplete, recommended, refused, withdrawn]
recommended: [approved, refused, withdrawn]
```
`transitions` is an arbitrary directed graph. Cycles are allowed and
are often the honest shape: a case sent back for the drawing it was
missing returns to a stage it was already past. A good ending may sit
partway along — an authority that changes its own decision finishes
the case without walking to the end of the line.
Each state's `event` string is the name written into the log, and must
be unique within the bucket. A record's state is replayed from its own
events, so renaming an event orphans the records that carry the old
name: when rebuilding a live site, keep the names it already has.
## What a state may carry
Beside its `event`, a state may declare three things that happen when
a record arrives in it.
### mail
```yaml
approved:
event: approved
mail:
to: submitter # or one fixed address
subject: "Your application"
body: |
Hello,
{property_address} is approved. The decision is at
{site}/status/{chain}.
```
`{field}` is any field of the record, plus `{site}`, `{chain}`,
`{email}`, `{bucket}` and `{state}`. The body is markdown; it is sent
as text with an HTML alternative. `to: submitter` means the record's
`email` field, and the lint then requires every form filling that
bucket to collect a required `email`.
Portal renders it and publishes on `portal.mail.send`. gdo delivers
it, and a reply comes back on `portal.mail.received` as a note beside
the record. A note never moves a case.
### grants
```yaml
approved:
event: approved
grants: approved_firms
```
The person the record is about becomes a member of that group when the
record arrives here. An approval that *means* membership should not
also need somebody to remember to invite them afterwards.
The lint requires every form filling that bucket to collect a required
`name` and a required `email`, because by the time a desk presses
approve there is nobody left to ask. An instance with no Kanidm
configured grants nothing and says so in the log; the transition
itself always stands.
### after
```yaml
on_hearing:
event: on_hearing
after: { days: 42, to: recommended }
```
The move a clock makes rather than a person. A hearing that gets no
answer in six weeks has to go on without one, and nobody in the
building is in a position to press that button.
A deadline may only do what a person could have done: `after.to` is
held to the same declared edge as a desk button, and content that says
otherwise is refused when it loads, not when the hour comes round. The
wait is measured from the record's last move. The move goes through
the same transition path, fires the same mail, and lands on
`portal.answers.submitted` like any decision, stamped `deadline`.
The unit is days and the sweep is hourly. This is a deadline, not a
job queue.
## attended_by
```yaml
- bucket: newsletter
initial: open
states: { open: { event: received } }
attended_by: n8n newsletter compose
```
Names what consumes a bucket no page reads back. The lint refuses a
bucket that a form fills and nothing attends — a question was once
dropped and its answers silently fell out of every reader's view, and
this is the contract that replaced the convention.
## Who moves what
Nothing in this file says who. A move becomes possible for somebody
when a page offers it:
- a desk resource's `transitions` gives it to that resource's
`requires_group`;
- an alternative's `self_transition` gives it to the person who sent
the record, by their link and their email, from the states its
`from` names;
- a state's `after` gives it to the clock.
All three compile into the same [access policy](access.md), and the
engine refuses anything the graph above does not declare, whoever asks.
## Testing one
`iris replay` runs cases through this exact machinery — the same
`store_answer` and `transition` the running site uses, against a
throwaway JetStream, reporting what the engine accepted and refused.
```sh
docker run -d --rm -p 127.0.0.1:14222:4222 nats:2.14.6-alpine -js
iris replay --path questions --nats nats://127.0.0.1:14222 --cases cases.jsonl
```
Each line of `cases.jsonl` is one case: a need, a record, and the
states it walks. Point it at a throwaway NATS, never a site's own: it
writes.
+304 -9
View File
@@ -43,12 +43,86 @@ const MAX_ECHOES = 8;
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
// ── Convergence animation (ink → what the network heard) ──────────
//
// When the ack lands, a glowing copy of the stroke peels off and
// converges point-by-point onto the key's decoded path: a wave of
// "being understood" travels from the stroke's start to its end,
// and the ghost materializes where the light settles. WebGL point
// sprites with additive glow on a dedicated overlay canvas; the 2d
// layer underneath never changes its final render, so losing WebGL
// (or prefers-reduced-motion) degrades to exactly today's behavior.
const CONVERGE_MS = 1500;
const CONVERGE_FADE_MS = 450;
const CONVERGE_POINTS = 128;
const CONVERGE_STAGGER = 0.6; // fraction of the timeline spent on the travelling wave
const GLOW_VERT = `
attribute vec2 aFrom;
attribute vec2 aTo;
attribute float aIndex;
uniform float uT;
uniform vec2 uResolution;
uniform float uDpr;
varying float vLife;
void main() {
float lead = aIndex * ${CONVERGE_STAGGER};
float p = clamp((uT * ${1.0 + CONVERGE_STAGGER} - lead), 0.0, 1.0);
float e = p < 0.5 ? 4.0 * p * p * p : 1.0 - pow(-2.0 * p + 2.0, 3.0) / 2.0;
vec2 pos = mix(aFrom, aTo, e);
vec2 clip = (pos / uResolution) * 2.0 - 1.0;
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
float pulse = 1.0 + 1.4 * sin(3.14159 * p);
gl_PointSize = 9.0 * uDpr * pulse;
vLife = p;
}`;
const GLOW_FRAG = `
precision mediump float;
uniform vec3 uColor;
uniform float uAlpha;
varying float vLife;
void main() {
vec2 d = gl_PointCoord - 0.5;
float fall = exp(-dot(d, d) * 18.0);
float breathe = 0.55 + 0.45 * sin(3.14159 * vLife);
gl_FragColor = vec4(uColor, 1.0) * (fall * uAlpha * breathe);
}`;
// Uniform arc-length resample of a pixel-space polyline to n points.
function resamplePx(points, n) {
if (points.length === 1) return Array(n).fill(points[0]);
const dists = [0];
for (let i = 1; i < points.length; i++) {
const dx = points[i][0] - points[i - 1][0];
const dy = points[i][1] - points[i - 1][1];
dists.push(dists[i - 1] + Math.hypot(dx, dy));
}
const total = dists[dists.length - 1] || 1;
const out = [];
let seg = 0;
for (let i = 0; i < n; i++) {
const target = (i / (n - 1)) * total;
while (seg < points.length - 2 && dists[seg + 1] < target) seg++;
const span = dists[seg + 1] - dists[seg] || 1;
const t = (target - dists[seg]) / span;
out.push([
points[seg][0] + (points[seg + 1][0] - points[seg][0]) * t,
points[seg][1] + (points[seg + 1][1] - points[seg][1]) * t,
]);
}
return out;
}
class GestureWidget {
constructor(container, hidden, relayUrl) {
this.container = container;
this.hidden = hidden || null;
this.relayUrl = relayUrl || '';
this.ghost = null;
this.ownKey = null;
this.selected = null; // {key, path, distance} - a place the visitor picked
this.seenKeys = new Set();
this.points = [];
this.drawing = false;
this.stopped = false;
@@ -60,9 +134,25 @@ class GestureWidget {
this.canvas = document.createElement('canvas');
this.canvas.className = 'gesture-canvas';
container.appendChild(this.canvas);
// Overlay for the convergence glow; sized with the main canvas,
// inert to pointers, empty until an ack arrives.
this.glow = document.createElement('canvas');
this.glow.className = 'gesture-glow';
this.glow.style.position = 'absolute';
this.glow.style.pointerEvents = 'none';
if (!container.style.position) container.style.position = 'relative';
container.appendChild(this.glow);
this.gl = null;
this.animFrame = null;
this.converging = false;
this.echoes = document.createElement('div');
this.echoes.className = 'gesture-echoes';
this.echoes.setAttribute('role', 'listbox');
this.echoes.setAttribute('aria-label', 'places near your shape');
container.appendChild(this.echoes);
this.empty = document.createElement('p');
this.empty.className = 'gesture-empty';
container.appendChild(this.empty);
if (this.relayUrl) {
this.status = document.createElement('span');
this.status.className = 'gesture-status offline';
@@ -102,6 +192,13 @@ class GestureWidget {
this.ctx.scale(dpr, dpr);
this.cssWidth = rect.width;
this.cssHeight = rect.height;
this.glow.style.left = `${this.canvas.offsetLeft}px`;
this.glow.style.top = `${this.canvas.offsetTop}px`;
this.glow.style.width = `${rect.width}px`;
this.glow.style.height = `${rect.height}px`;
this.glow.width = this.canvas.width;
this.glow.height = this.canvas.height;
this.cancelConvergence(); // buffers are in old pixels
}
pos(e) {
@@ -115,7 +212,13 @@ class GestureWidget {
// One stroke only - a new pointerdown replaces the old drawing
// (and any ghost from the previous round).
this.drawing = true;
this.cancelConvergence();
this.ghost = null;
this.ownKey = null;
this.selected = null;
this.seenKeys.clear();
this.echoes.replaceChildren();
this.empty.textContent = '';
this.points = [this.pos(e)];
this.render();
}
@@ -214,14 +317,20 @@ class GestureWidget {
return;
}
if (msg.type === 'ack') {
if (this.points.length >= 2) {
this.setValue({ points: this.normalized(), key: msg.key });
}
this.ownKey = msg.key;
// Version nibble is the key's first hex digit.
const version = parseInt((msg.key || '0')[0], 16);
this.ghost = version >= 2 && Array.isArray(msg.path) ? msg.path : null;
this.render();
} else if (msg.type === 'echo') {
this.ownGhost = version >= 2 && Array.isArray(msg.path) ? msg.path : null;
this.applySelection();
// Places arrive right after the ack; say so if none do.
clearTimeout(this.emptyTimer);
this.emptyTimer = setTimeout(() => {
if (!this.echoes.children.length) this.empty.textContent = msg.empty || 'Nothing kept at this shape yet.';
}, 1500);
} else if (msg.type === 'echo' || msg.type === 'place') {
if (!msg.key || this.seenKeys.has(msg.key)) return;
this.seenKeys.add(msg.key);
this.empty.textContent = '';
this.addEchoThumbnail(msg);
}
// 'error' frames are intentionally silent: the widget is a
@@ -229,9 +338,56 @@ class GestureWidget {
// alarm anyone mid-form.
}
// The key the input carries: the visitor's own, or the place they
// picked (selection re-derives the key - the ghost under the
// stroke becomes that place's decode, and the answer records both).
applySelection() {
if (this.points.length < 2) return;
const points = this.normalized();
const before = this.ghost;
if (this.selected) {
this.ghost = this.selected.path;
this.setValue({ points, key: this.selected.key, own_key: this.ownKey, selected_from: this.ownKey, selected_distance: this.selected.distance });
} else {
this.ghost = this.ownGhost || null;
this.setValue({ points, key: this.ownKey });
}
if (this.ghost && this.ghost !== before) this.startConvergence(this.ghost);
for (const el of this.echoes.children) {
el.classList.toggle('selected', !!this.selected && el.dataset.key === this.selected.key);
el.setAttribute('aria-selected', String(!!this.selected && el.dataset.key === this.selected.key));
}
this.render();
}
select(thumb) {
const key = thumb.dataset.key;
if (this.selected && this.selected.key === key) {
this.selected = null;
} else {
this.selected = { key, path: JSON.parse(thumb.dataset.path), distance: Number(thumb.dataset.distance) };
// The relay counts picks per place for ranking.
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.ownKey) {
this.ws.send(JSON.stringify({ type: 'select', key, from: this.ownKey, distance: this.selected.distance }));
}
}
this.applySelection();
}
addEchoThumbnail(msg) {
const thumb = document.createElement('canvas');
thumb.className = 'gesture-echo';
thumb.className = 'gesture-echo' + (msg.type === 'place' ? ' place' : '');
thumb.dataset.key = msg.key;
thumb.dataset.path = JSON.stringify(msg.path || []);
thumb.dataset.distance = String(msg.distance || 0);
thumb.setAttribute('role', 'option');
thumb.setAttribute('tabindex', '0');
thumb.setAttribute('aria-selected', 'false');
thumb.title = msg.type === 'place'
? `A place with ${msg.recordings || 0} recording${msg.recordings === 1 ? '' : 's'} - tap to make it your address`
: 'Someone at this shape right now';
thumb.addEventListener('click', () => this.select(thumb));
thumb.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.select(thumb); } });
// Closer strokes render stronger; 500 is comfortably past the
// relay's default threshold, so everything stays visible.
const strength = Math.max(0.35, Math.min(1, 1 - msg.distance / 500));
@@ -253,6 +409,140 @@ class GestureWidget {
setTimeout(() => thumb.classList.add('shown'), 30);
}
// ── Convergence ────────────────────────────────────────────────
initGlow() {
if (this.gl) return this.gl;
const gl = this.glow.getContext('webgl', { alpha: true, premultipliedAlpha: true });
if (!gl) return null;
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
};
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, GLOW_VERT));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, GLOW_FRAG));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
gl.useProgram(prog);
this.gl = gl;
this.glProg = prog;
this.glLoc = {
aFrom: gl.getAttribLocation(prog, 'aFrom'),
aTo: gl.getAttribLocation(prog, 'aTo'),
aIndex: gl.getAttribLocation(prog, 'aIndex'),
uT: gl.getUniformLocation(prog, 'uT'),
uResolution: gl.getUniformLocation(prog, 'uResolution'),
uDpr: gl.getUniformLocation(prog, 'uDpr'),
uColor: gl.getUniformLocation(prog, 'uColor'),
uAlpha: gl.getUniformLocation(prog, 'uAlpha'),
};
this.glBufFrom = gl.createBuffer();
this.glBufTo = gl.createBuffer();
this.glBufIndex = gl.createBuffer();
return gl;
}
// The ghost's pixel frame - same fit drawPath uses.
ghostToPx(path) {
const scale = (Math.min(this.cssWidth, this.cssHeight) / 2) * 0.8;
const cx = this.cssWidth / 2, cy = this.cssHeight / 2;
return path.map(([x, y]) => [cx + x * scale, cy + y * scale]);
}
startConvergence(targetPath) {
this.cancelConvergence();
if (!targetPath || this.points.length < 2) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const gl = this.initGlow();
if (!gl) return;
const dpr = window.devicePixelRatio || 1;
const from = resamplePx(this.points.map((p) => [p.x, p.y]), CONVERGE_POINTS);
const to = resamplePx(this.ghostToPx(targetPath), CONVERGE_POINTS);
const f = new Float32Array(CONVERGE_POINTS * 2);
const t = new Float32Array(CONVERGE_POINTS * 2);
const idx = new Float32Array(CONVERGE_POINTS);
for (let i = 0; i < CONVERGE_POINTS; i++) {
f[i * 2] = from[i][0] * dpr;
f[i * 2 + 1] = from[i][1] * dpr;
t[i * 2] = to[i][0] * dpr;
t[i * 2 + 1] = to[i][1] * dpr;
idx[i] = i / (CONVERGE_POINTS - 1);
}
const bind = (buf, data, loc, size) => {
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
};
gl.useProgram(this.glProg);
bind(this.glBufFrom, f, this.glLoc.aFrom, 2);
bind(this.glBufTo, t, this.glLoc.aTo, 2);
bind(this.glBufIndex, idx, this.glLoc.aIndex, 1);
gl.viewport(0, 0, this.glow.width, this.glow.height);
gl.uniform2f(this.glLoc.uResolution, this.glow.width, this.glow.height);
gl.uniform1f(this.glLoc.uDpr, dpr);
const light = this._themeQuery.matches;
const color = light ? [0x47 / 255, 0x80 / 255, 0x7e / 255] : [0x8e / 255, 0xc2 / 255, 0xc0 / 255];
gl.uniform3f(this.glLoc.uColor, color[0], color[1], color[2]);
// Additive light on the dark ground; normal ink on the light one.
gl.enable(gl.BLEND);
if (light) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
else gl.blendFunc(gl.ONE, gl.ONE);
this.converging = true;
this.render();
const started = performance.now();
const step = (now) => {
const raw = (now - started) / CONVERGE_MS;
if (raw >= 1) {
// Deposit the ghost, then let the glow breathe out.
if (this.converging) {
this.converging = false;
this.render();
}
const fade = (now - started - CONVERGE_MS) / CONVERGE_FADE_MS;
if (fade >= 1) {
this.cancelConvergence();
return;
}
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(this.glLoc.uT, 1);
gl.uniform1f(this.glLoc.uAlpha, 0.32 * (1 - fade));
gl.drawArrays(gl.POINTS, 0, CONVERGE_POINTS);
} else {
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Trailing passes give the wave a comet tail.
for (const [lag, alpha] of [[0, 0.32], [0.035, 0.16], [0.07, 0.08], [0.105, 0.04]]) {
gl.uniform1f(this.glLoc.uT, Math.max(0, raw - lag));
gl.uniform1f(this.glLoc.uAlpha, alpha);
gl.drawArrays(gl.POINTS, 0, CONVERGE_POINTS);
}
}
this.animFrame = requestAnimationFrame(step);
};
this.animFrame = requestAnimationFrame(step);
}
cancelConvergence() {
if (this.animFrame) cancelAnimationFrame(this.animFrame);
this.animFrame = null;
if (this.converging) {
this.converging = false;
this.render();
}
if (this.gl) {
this.gl.clearColor(0, 0, 0, 0);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
}
}
// ── Rendering ──────────────────────────────────────────────────
// Draw a normalized (-1..1) path fitted into w×h with padding.
@@ -277,7 +567,7 @@ class GestureWidget {
if (!this.ctx) return;
const t = this.theme();
this.ctx.clearRect(0, 0, this.cssWidth, this.cssHeight);
if (this.ghost) {
if (this.ghost && !this.converging) {
this.drawPath(this.ctx, this.ghost, this.cssWidth, this.cssHeight, t.ghost, 2, 0.2);
}
if (this.points.length >= 1) {
@@ -296,6 +586,8 @@ class GestureWidget {
stop() {
this.stopped = true;
if (this.animFrame) cancelAnimationFrame(this.animFrame);
clearTimeout(this.emptyTimer);
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.ws) {
// The close event still fires, but scheduleReconnect
@@ -313,5 +605,8 @@ class GestureWidget {
}
export function mountGesture(container, hidden, relayUrl) {
return new GestureWidget(container, hidden, relayUrl);
// Dev convenience: `?relay=ws://127.0.0.1:9040` points every widget
// on the page at a local relay (prod relays reject foreign Origins).
const override = new URLSearchParams(window.location.search).get('relay');
return new GestureWidget(container, hidden, override || relayUrl);
}
+128
View File
@@ -0,0 +1,128 @@
// Glue for `type: location` requirement fields (see AlternativeCard in
// src/app.rs) - the same hidden-input bridge every widget kind uses,
// and the same typed handle with stop() that gesture.js and voice.js
// use so SPA navigation tears the map down.
//
// The pin is the centre of the map, not a draggable marker: panning
// works the same with a finger, a mouse or the arrow keys, and there
// is nothing to miss by a few pixels. Whatever the map is looking at
// when it stops moving is the answer, written into the paired hidden
// <input> as {"lat","lon","zoom"} with a real `input` event, so the
// Rust side reuses the signal wiring every other field already has.
//
// MapLibre is vendored and served from this origin (/maplibre-gl.js),
// and every tile comes from this origin too (/map/tiles/...), so a
// page with a map on it still talks to nobody but us.
const WAIT_MS = 15000;
async function maplibre() {
const started = Date.now();
while (!window.maplibregl) {
if (Date.now() - started > WAIT_MS) throw new Error('maplibre-gl did not load');
await new Promise((r) => setTimeout(r, 50));
}
return window.maplibregl;
}
// The value as stored: five decimals is about a metre, which is as
// much as a pin on a map means.
function place(map) {
const c = map.getCenter();
return {
lat: Number(c.lat.toFixed(5)),
lon: Number(c.lng.toFixed(5)),
zoom: Number(map.getZoom().toFixed(2)),
};
}
// Where to open: what the field already holds, else what the content
// gave as `value` ("lat,lon,zoom"), else the whole world, which is
// honest about knowing nothing rather than guessing a country.
function opening(hidden, initial) {
try {
const held = JSON.parse(hidden.value || 'null');
if (held && typeof held.lat === 'number' && typeof held.lon === 'number') {
return { center: [held.lon, held.lat], zoom: held.zoom ?? 13, pinned: true };
}
} catch {}
const parts = String(initial || '').split(',').map((p) => Number(p.trim()));
if (parts.length >= 2 && parts.every((n) => Number.isFinite(n))) {
return { center: [parts[1], parts[0]], zoom: parts[2] ?? 13, pinned: false };
}
return { center: [0, 20], zoom: 1, pinned: false };
}
class MapWidget {
constructor(container, hidden, initial) {
this.container = container;
this.map = null;
this.dead = false;
this.start(container, hidden, initial);
}
async start(container, hidden, initial) {
let gl;
try {
gl = await maplibre();
} catch (e) {
container.textContent = 'The map could not be loaded. ' + (hidden.value ? 'Your saved place is kept.' : '');
return;
}
if (this.dead) return;
const at = opening(hidden, initial);
const canvas = document.createElement('div');
canvas.className = 'map-canvas';
const pin = document.createElement('div');
pin.className = 'map-pin';
pin.setAttribute('aria-hidden', 'true');
const readout = document.createElement('p');
readout.className = 'map-readout';
container.append(canvas, pin, readout);
const map = new gl.Map({
container: canvas,
style: '/map/style.json',
center: at.center,
zoom: at.zoom,
attributionControl: { compact: true },
});
this.map = map;
map.addControl(new gl.NavigationControl({ showCompass: false }), 'top-right');
map.addControl(
new gl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: false }),
'top-right',
);
// Nothing is answered until the map has been moved, or was opened
// on an answer it already had: an untouched map means "no place",
// not "the middle of the ocean".
let answered = at.pinned;
const write = () => {
if (!answered) return;
const value = place(map);
hidden.value = JSON.stringify(value);
hidden.dispatchEvent(new Event('input', { bubbles: true }));
readout.textContent = `${value.lat}, ${value.lon}`;
};
map.on('moveend', () => {
answered = true;
write();
});
map.on('load', write);
}
stop() {
this.dead = true;
if (this.map) {
this.map.remove();
this.map = null;
}
this.container.replaceChildren();
}
}
export function mountMap(container, hidden, initial) {
return new MapWidget(container, hidden, initial);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+650
View File
@@ -0,0 +1,650 @@
//! The one place a request is allowed or refused.
//!
//! Content already says who may do what: `qualifies` on a page,
//! `requires_group`/`public` on a resource, the transitions a desk
//! offers, a `self_transition` a submitter may fire with their own
//! link, an `invite` a group may extend. Until now those were five
//! checks in five files. Here they are compiled, on every content
//! load, into one Cedar policy set, and every server entry point asks
//! the same question: may this principal take this action on this
//! resource? The answer carries the ids of the rules that granted it,
//! and every decision that matters is published on NATS.
//!
//! The policy text is derived, never hand-written: content is the
//! source, this module is the compiler, iris prints the result as a
//! matrix and validates it against the schema.
#![cfg(feature = "ssr")]
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use cedar_policy::{
Authorizer, Context, Entities, Entity, EntityId, EntityTypeName, EntityUid, PolicySet, Request,
RestrictedExpression, Schema, ValidationMode, Validator,
};
use serde::{Deserialize, Serialize};
use crate::aggregates::AggregateSchema;
use crate::content::{path_matches, Question};
/// What every entity and action may look like. The generated policies
/// are validated against this, so a content shape the compiler does
/// not know how to express fails loudly instead of granting nothing.
pub const SCHEMA: &str = r#"
entity Group;
entity User in [Group];
entity Submitter { email: String };
entity Service;
entity Page;
entity Bucket;
entity Feature;
action open, submit appliesTo { principal: [User], resource: [Page] };
action read appliesTo { principal: [User], resource: [Feature] };
action transition appliesTo { principal: [User], resource: [Bucket], context: { from: String, to: String } };
action self_transition appliesTo { principal: [Submitter], resource: [Bucket], context: { from: String, to: String, stored_email: String } };
action automation_read appliesTo { principal: [Service], resource: [Bucket] };
action invite appliesTo { principal: [User], resource: [Group] };
"#;
/// Who is asking.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Principal {
/// A signed-in person, or nobody (`groups` empty, `sub` "anonymous").
User { sub: String, groups: Vec<String> },
/// Whoever holds one record's own link and knows the email on it.
Submitter { record: String, email: String },
/// A machine with a shared token.
Service { name: String },
}
impl Principal {
pub fn anonymous() -> Self {
Principal::User { sub: "anonymous".into(), groups: Vec::new() }
}
pub fn from_user(user: Option<&crate::auth::User>) -> Self {
match user {
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone() },
None => Principal::anonymous(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
Open,
Submit,
Read,
Transition,
SelfTransition,
AutomationRead,
Invite,
}
impl Action {
fn name(self) -> &'static str {
match self {
Action::Open => "open",
Action::Submit => "submit",
Action::Read => "read",
Action::Transition => "transition",
Action::SelfTransition => "self_transition",
Action::AutomationRead => "automation_read",
Action::Invite => "invite",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Resource {
Page { id: String },
Bucket { name: String },
/// One resource block on one alternative of one page. `feature` is
/// the feature name, or `feature|requirement` (`|bind` appended)
/// for a requirement's own resource.
Feature { page: String, alternative: String, feature: String },
Group { name: String },
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Decision {
pub allow: bool,
/// The ids of the rules that granted it (empty on a refusal).
pub reasons: Vec<String>,
}
/// One row of the matrix iris prints: who may do what to what.
#[derive(Clone, Debug, Serialize)]
pub struct Rule {
pub id: String,
pub who: String,
pub action: String,
pub resource: String,
pub when: String,
}
pub struct Policy {
policies: PolicySet,
schema: Schema,
pub rules: Vec<Rule>,
pub text: String,
/// The dynamic pages' pattern ids, so a concrete `/shape/abc`
/// resolves to the policy written for `/shape/[key]`.
patterns: Vec<String>,
}
fn quote(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
impl Policy {
/// Compiles content into policy. Infallible on valid content: every
/// rule below is a shape the schema declares; the validation in
/// `validate` is what iris runs to prove that stays true.
pub fn from_content(
questions: &HashMap<String, Question>,
aggregates: &HashMap<String, AggregateSchema>,
) -> Self {
let mut text = String::new();
let mut rules = Vec::new();
let mut ids: HashMap<String, usize> = HashMap::new();
// Each rule is parsed on its own under its own id, so a
// decision's reasons name rules, not `policy17`.
let mut sources: Vec<(String, String)> = Vec::new();
let mut emit = |id_base: String, who: String, action: &str, resource: String, when: String, policy: String, text: &mut String, rules: &mut Vec<Rule>| {
let n = ids.entry(id_base.clone()).or_insert(0);
*n += 1;
let id = if *n == 1 { id_base } else { format!("{id_base}#{n}") };
text.push_str(&format!("@id({})\n{policy}\n\n", quote(&id)));
rules.push(Rule { id: id.clone(), who, action: action.to_string(), resource, when });
sources.push((id, policy));
};
let mut pages: Vec<&Question> = questions.values().collect();
pages.sort_by(|a, b| a.id.cmp(&b.id));
for q in &pages {
let page = quote(&q.id);
let (who, principal) = match &q.qualifies {
None => ("anyone".to_string(), "principal".to_string()),
Some(g) => (format!("group {g}"), format!("principal in Group::{}", quote(g))),
};
emit(
format!("open:{}", q.id), who, "open, submit", format!("page {}", q.id), String::new(),
format!("permit({principal}, action in [Action::\"open\", Action::\"submit\"], resource == Page::{page});"),
&mut text, &mut rules,
);
for alt in &q.alternatives {
// A feature's own resource, and the resources its
// requirements read (select options, bound fields):
// each is a Feature entity of its own.
let mut specs: Vec<(String, &crate::content::ResourceSpec)> = Vec::new();
for feature in &alt.features {
if let Some(spec) = &feature.resource {
specs.push((format!("{}|{}|{}", q.id, alt.name, feature.name), spec));
}
for req in &feature.requirements {
if let Some(spec) = &req.resource {
specs.push((format!("{}|{}|{}|{}", q.id, alt.name, feature.name, req.name), spec));
}
if let Some(bind) = &req.bind {
specs.push((format!("{}|{}|{}|{}|bind", q.id, alt.name, feature.name, req.name), &bind.resource));
}
}
}
for (fid, spec) in specs {
let (who, principal) = match (&spec.requires_group, spec.public) {
(Some(g), _) => (format!("group {g}"), format!("principal in Group::{}", quote(g))),
(None, true) => ("anyone".to_string(), "principal".to_string()),
// Neither: unreachable by design (fail closed).
(None, false) => continue,
};
emit(
format!("read:{fid}"), who.clone(), "read", format!("feature {fid}"), String::new(),
format!("permit({principal}, action == Action::\"read\", resource == Feature::{});", quote(&fid)),
&mut text, &mut rules,
);
if let (Some(bucket), Some(g)) = (spec.bucket(), &spec.requires_group) {
for t in &spec.transitions {
emit(
format!("move:{bucket}:{}>{}", t.from, t.to), format!("group {g}"), "transition",
format!("bucket {bucket}"), format!("{} -> {}", t.from, t.to),
format!(
"permit(principal in Group::{}, action == Action::\"transition\", resource == Bucket::{}) when {{ context.from == {} && context.to == {} }};",
quote(g), quote(bucket), quote(&t.from), quote(&t.to)
),
&mut text, &mut rules,
);
}
}
}
if let Some(st) = &alt.self_transition {
// One rule per state the move may be made from: a
// person stays in dialogue with their case for as
// long as it runs, and `from` says how long that
// is. Saying nothing means the bucket's own
// initial state - the bucket's, not the word
// `open`, since a bucket may start anywhere.
let initial = aggregates
.get(&st.bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| crate::answers::OPEN_STATE.to_string());
for from in &st.from_states(&initial) {
emit(
format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition",
format!("bucket {}", st.bucket), format!("{from} -> {}", st.to),
format!(
"permit(principal is Submitter, action == Action::\"self_transition\", resource == Bucket::{}) when {{ context.from == {} && context.to == {} && principal.email == context.stored_email }};",
quote(&st.bucket), quote(from), quote(&st.to)
),
&mut text, &mut rules,
);
}
}
if let (Some(invite), Some(g)) = (&alt.invite, &q.qualifies) {
emit(
format!("invite:{}", invite.group), format!("group {g}"), "invite", format!("group {}", invite.group), String::new(),
format!("permit(principal in Group::{}, action == Action::\"invite\", resource == Group::{});", quote(g), quote(&invite.group)),
&mut text, &mut rules,
);
}
}
}
// The one machine principal: the automation read endpoint, which
// authenticates by shared token and may read any bucket.
let mut buckets: Vec<&String> = aggregates.keys().collect();
buckets.sort();
emit(
"automation".to_string(), "service automation".to_string(), "automation_read", "any bucket".to_string(), String::new(),
"permit(principal == Service::\"automation\", action == Action::\"automation_read\", resource);".to_string(),
&mut text, &mut rules,
);
let _ = buckets;
let mut policies = PolicySet::new();
for (id, source) in &sources {
let policy = cedar_policy::Policy::parse(Some(cedar_policy::PolicyId::new(id)), source)
.expect("generated policy text always parses");
policies.add(policy).expect("rule ids are unique");
}
let schema = Schema::from_cedarschema_str(SCHEMA).map(|(s, _)| s).expect("the schema is a constant");
let patterns = questions.values().filter(|q| q.is_dynamic()).map(|q| q.id.clone()).collect();
Policy { policies, schema, rules, text, patterns }
}
/// Every generated rule type-checks against the schema. A rule the
/// schema cannot express is a compiler bug, and this is where it
/// shows.
pub fn validate(&self) -> Result<(), String> {
let result = Validator::new(self.schema.clone()).validate(&self.policies, ValidationMode::Strict);
if result.validation_passed() {
Ok(())
} else {
Err(result.validation_errors().map(|e| e.to_string()).collect::<Vec<_>>().join("; "))
}
}
fn page_entity(&self, id: &str) -> String {
if self.patterns.iter().any(|p| p == id) {
return id.to_string();
}
self.patterns
.iter()
.find(|p| path_matches(p, id).is_some())
.cloned()
.unwrap_or_else(|| id.to_string())
}
/// The decision. `context` holds what the action needs: `from`/`to`
/// for a transition, plus `stored_email` for a self transition.
pub fn decide(
&self,
principal: &Principal,
action: Action,
resource: &Resource,
context: serde_json::Value,
) -> Decision {
let uid = |ty: &str, id: &str| {
EntityUid::from_type_name_and_id(EntityTypeName::from_str(ty).expect("static type"), EntityId::new(id))
};
let mut entities: Vec<Entity> = Vec::new();
let principal_uid = match principal {
Principal::User { sub, groups } => {
let parents: HashSet<EntityUid> = groups.iter().map(|g| uid("Group", g)).collect();
for g in &parents {
entities.push(Entity::new_no_attrs(g.clone(), HashSet::new()));
}
let me = uid("User", sub);
entities.push(Entity::new_no_attrs(me.clone(), parents));
me
}
Principal::Submitter { record, email } => {
let me = uid("Submitter", record);
let attrs = HashMap::from([("email".to_string(), RestrictedExpression::new_string(email.clone()))]);
entities.push(Entity::new(me.clone(), attrs, HashSet::new()).expect("a string attribute always evaluates"));
me
}
Principal::Service { name } => {
let me = uid("Service", name);
entities.push(Entity::new_no_attrs(me.clone(), HashSet::new()));
me
}
};
let resource_uid = match resource {
Resource::Page { id } => uid("Page", &self.page_entity(id)),
Resource::Bucket { name } => uid("Bucket", name),
Resource::Feature { page, alternative, feature } => {
uid("Feature", &format!("{}|{alternative}|{feature}", self.page_entity(page)))
}
Resource::Group { name } => uid("Group", name),
};
entities.push(Entity::new_no_attrs(resource_uid.clone(), HashSet::new()));
let action_uid = uid("Action", action.name());
let Ok(entities) = Entities::from_entities(entities, None) else {
return Decision::default();
};
// An absent context is an empty one, not a malformed one.
let context = if context.is_object() { context } else { serde_json::json!({}) };
let Ok(context) = Context::from_json_value(context, None) else {
return Decision::default();
};
let Ok(request) = Request::new(principal_uid, action_uid, resource_uid, context, None) else {
return Decision::default();
};
let response = Authorizer::new().is_authorized(&request, &self.policies, &entities);
Decision {
allow: response.decision() == cedar_policy::Decision::Allow,
reasons: response.diagnostics().reason().map(|id| id.to_string()).collect(),
}
}
pub fn allows(&self, principal: &Principal, action: Action, resource: &Resource) -> bool {
self.decide(principal, action, resource, serde_json::json!({})).allow
}
}
/// What gets published on `portal.access.decided`: the question and
/// the answer. Page opens and reads are published only when refused;
/// every mutation is published either way.
#[derive(Clone, Debug, Serialize)]
pub struct Decided<'a> {
pub principal: &'a Principal,
pub action: Action,
pub resource: &'a Resource,
pub context: &'a serde_json::Value,
pub allow: bool,
pub reasons: &'a [String],
pub at_ms: i64,
}
pub const DECIDED_SUBJECT: &str = "portal.access.decided";
pub fn worth_publishing(action: Action, allow: bool) -> bool {
!allow || !matches!(action, Action::Open | Action::Read)
}
/// Decides and, when it matters, publishes. Fire and forget: a NATS
/// hiccup never changes an answer.
pub fn decide_and_publish(
nats: &async_nats::Client,
policy: &Policy,
principal: &Principal,
action: Action,
resource: &Resource,
context: serde_json::Value,
) -> Decision {
let decision = policy.decide(principal, action, resource, context.clone());
if worth_publishing(action, decision.allow) {
let event = Decided {
principal,
action,
resource,
context: &context,
allow: decision.allow,
reasons: &decision.reasons,
at_ms: chrono::Utc::now().timestamp_millis(),
};
if let Ok(bytes) = serde_json::to_vec(&event) {
let nats = nats.clone();
tokio::spawn(async move {
let _ = nats.publish(DECIDED_SUBJECT, bytes.into()).await;
});
}
}
decision
}
/// The request-reply face for other apps: a JSON question in, a
/// decision out. Same policy, same answer as the site gives itself.
#[derive(Debug, Deserialize)]
pub struct Query {
pub principal: Principal,
pub action: Action,
pub resource: Resource,
#[serde(default)]
pub context: serde_json::Value,
}
pub fn subject_for(site: &str) -> String {
format!("portal.access.{site}.may")
}
pub fn policy_subject_for(site: &str) -> String {
format!("portal.access.{site}.policy")
}
/// Answers `portal.access.<site>.may` for as long as the process runs,
/// with the policy as it is at the moment of each question (it is
/// swapped on content reload). A malformed question gets a refusal
/// with the parse error as its reason, never silence.
pub async fn serve(
nats: async_nats::Client,
site: String,
access: std::sync::Arc<arc_swap::ArcSwap<Policy>>,
) {
use futures::StreamExt;
let subject = subject_for(&site);
let mut sub = match nats.subscribe(subject.clone()).await {
Ok(sub) => sub,
Err(e) => {
tracing::error!(error = %e, %subject, "cannot serve access decisions");
return;
}
};
tracing::info!(%subject, "answering access questions");
while let Some(msg) = sub.next().await {
let Some(reply) = msg.reply else { continue };
let decision = match serde_json::from_slice::<Query>(&msg.payload) {
Ok(q) => decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context),
Err(e) => Decision { allow: false, reasons: vec![format!("malformed question: {e}")] },
};
let bytes = serde_json::to_vec(&decision).unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec());
let _ = nats.publish(reply, bytes.into()).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aggregates::parse_aggregates_yaml;
use crate::content::build_questions;
use serde_json::json;
fn site() -> Policy {
let files = vec![
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: Apply\n action: /applied\n record_as: applicants\n - name: Unsubscribe\n self_transition: { bucket: subscribers, to: unsubscribed, label: Go }\n".to_string()),
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
("shape/[key].yaml".to_string(), "name: Shape\n".to_string()),
("review/_section.yaml".to_string(), "qualifies: owners\n".to_string()),
("review/index.yaml".to_string(), "name: Review\nalternatives:\n - name: Applicants\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: applicants }\n requires_group: owners\n transitions:\n - { to: invited, label: Invite }\n - { from: invited, to: active, label: Onboard }\n - name: Releases\n resource:\n public: true\n source: { kind: gitea_releases, owner: o, repo: r }\n - name: Invite a colleague\n invite: { group: owners }\n features:\n - name: \"\"\n requirements:\n - { name: name }\n - { name: email, type: email }\n".to_string()),
];
let questions = build_questions(&files).unwrap();
let aggregates = parse_aggregates_yaml("aggregates:\n - bucket: applicants\n initial: open\n states:\n open: { event: a }\n invited: { event: b }\n active: { event: c }\n transitions:\n open: [invited]\n invited: [active]\n").unwrap();
Policy::from_content(&questions, &aggregates)
}
fn user(groups: &[&str]) -> Principal {
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect() }
}
#[test]
fn the_generated_policy_validates_against_the_schema() {
let p = site();
assert_eq!(p.validate(), Ok(()), "{}", p.text);
assert!(p.rules.iter().any(|r| r.id == "move:applicants:open>invited"));
}
#[test]
fn pages_open_to_anyone_unless_qualified() {
let p = site();
let page = |id: &str| Resource::Page { id: id.into() };
assert!(p.allows(&Principal::anonymous(), Action::Open, &page("/")));
assert!(!p.allows(&Principal::anonymous(), Action::Open, &page("/review")));
assert!(!p.allows(&user(&["staff"]), Action::Submit, &page("/review")));
let d = p.decide(&user(&["owners"]), Action::Submit, &page("/review"), json!({}));
assert!(d.allow);
assert_eq!(d.reasons, vec!["open:/review"]);
// A concrete dynamic path resolves to its pattern's rule.
assert!(p.allows(&Principal::anonymous(), Action::Open, &page("/shape/abc")));
assert!(!p.allows(&Principal::anonymous(), Action::Open, &page("/nowhere")));
}
#[test]
fn reads_follow_requires_group_or_public() {
let p = site();
let desk = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "".into() };
let releases = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "Releases".into() };
assert!(p.allows(&user(&["owners"]), Action::Read, &desk));
assert!(!p.allows(&user(&["staff"]), Action::Read, &desk));
assert!(p.allows(&Principal::anonymous(), Action::Read, &releases));
}
#[test]
fn transitions_are_exactly_the_desks_buttons() {
let p = site();
let bucket = Resource::Bucket { name: "applicants".into() };
let ctx = |from: &str, to: &str| json!({ "from": from, "to": to });
assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "invited")).allow);
assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("invited", "active")).allow);
// Right target, wrong starting state: no rule.
assert!(!p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "active")).allow);
assert!(!p.decide(&user(&["staff"]), Action::Transition, &bucket, ctx("open", "invited")).allow);
}
#[test]
fn a_submitter_moves_their_own_record_only_with_the_matching_email() {
let p = site();
let bucket = Resource::Bucket { name: "subscribers".into() };
let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() };
let ctx = |stored: &str| json!({ "from": "open", "to": "unsubscribed", "stored_email": stored });
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("x@y.z")).allow);
assert!(!p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "open", "to": "somewhere", "stored_email": "a@b.c" })).allow);
// A signed-in owner has no such rule: the link is the credential.
assert!(!p.decide(&user(&["owners"]), Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
}
// The applicant written to for a missing drawing sends it from
// `incomplete`, and may withdraw at any point up to the signature.
// Before `from`, every one of those moves was compiled as if it
// could only be made on the day the case arrived.
#[test]
fn a_submitter_stays_in_dialogue_for_as_long_as_the_case_runs() {
let files = vec![(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: Apply\n record_as: cases\n - name: Send the papers\n self_transition: { bucket: cases, to: complete, from: [open, incomplete], label: Send }\n - name: Withdraw\n self_transition: { bucket: cases, to: withdrawn, from: [open, incomplete, complete], label: Withdraw }\n".to_string(),
)];
let questions = build_questions(&files).unwrap();
let aggregates = parse_aggregates_yaml(
"aggregates:\n - bucket: cases\n initial: open\n states:\n open: { event: a }\n incomplete: { event: b }\n complete: { event: c }\n withdrawn: { event: d }\n transitions:\n open: [incomplete, complete, withdrawn]\n incomplete: [complete, withdrawn]\n complete: [withdrawn]\n",
)
.unwrap();
let p = Policy::from_content(&questions, &aggregates);
assert_eq!(p.validate(), Ok(()), "{}", p.text);
let bucket = Resource::Bucket { name: "cases".into() };
let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() };
let ctx = |from: &str, to: &str| json!({ "from": from, "to": to, "stored_email": "a@b.c" });
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("incomplete", "complete")).allow);
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "withdrawn")).allow);
// And only where it was said: the paperwork cannot be sent
// after the case is already withdrawn.
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("withdrawn", "complete")).allow);
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "complete")).allow);
}
// Omitted, `from` is the initial state alone - what every
// unsubscribe link written before this existed already meant.
#[test]
fn a_move_that_does_not_say_where_from_is_a_one_shot_on_arrival() {
let p = site();
assert!(p.rules.iter().any(|r| r.id == "self:subscribers:open>unsubscribed"));
assert_eq!(p.rules.iter().filter(|r| r.id.starts_with("self:subscribers:")).count(), 1);
}
// "On arrival" is the bucket's own first state, not the word
// `open`. redoal's watchers arrive at `pending` and confirm
// themselves into `open`; reading the literal made that rule
// `open -> open`, which the engine declares nowhere, so the
// confirm link in the mail could never have worked.
#[test]
fn arrival_is_wherever_the_bucket_actually_starts() {
let files = vec![(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: Ask\n record_as: watchers\n - name: Yes, watch it\n self_transition: { bucket: watchers, to: open, label: Yes }\n".to_string(),
)];
let questions = build_questions(&files).unwrap();
let aggregates = parse_aggregates_yaml(
"aggregates:\n - bucket: watchers\n initial: pending\n states:\n pending: { event: requested }\n open: { event: confirmed }\n transitions:\n pending: [open]\n",
)
.unwrap();
let p = Policy::from_content(&questions, &aggregates);
assert_eq!(p.validate(), Ok(()), "{}", p.text);
assert!(
p.rules.iter().any(|r| r.id == "self:watchers:pending>open"),
"the rule must start where the records do: {:?}",
p.rules.iter().map(|r| &r.id).collect::<Vec<_>>()
);
let me = Principal::Submitter { record: "r".into(), email: "a@b.c".into() };
let bucket = Resource::Bucket { name: "watchers".into() };
assert!(p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "pending", "to": "open", "stored_email": "a@b.c" })).allow);
}
#[test]
fn invites_and_automation() {
let p = site();
let owners = Resource::Group { name: "owners".into() };
assert!(p.allows(&user(&["owners"]), Action::Invite, &owners));
assert!(!p.allows(&user(&["staff"]), Action::Invite, &owners));
let bucket = Resource::Bucket { name: "applicants".into() };
assert!(p.allows(&Principal::Service { name: "automation".into() }, Action::AutomationRead, &bucket));
assert!(!p.allows(&Principal::Service { name: "someone".into() }, Action::AutomationRead, &bucket));
assert!(!p.allows(&user(&["owners"]), Action::AutomationRead, &bucket));
}
#[test]
fn a_question_over_nats_is_the_same_question() {
let p = site();
let q: Query = serde_json::from_str(
r#"{"principal":{"kind":"user","sub":"n8n-as-owner","groups":["owners"]},"action":"transition","resource":{"kind":"bucket","name":"applicants"},"context":{"from":"open","to":"invited"}}"#,
)
.unwrap();
assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow);
let q: Query = serde_json::from_str(
r#"{"principal":{"kind":"service","name":"automation"},"action":"automation_read","resource":{"kind":"bucket","name":"applicants"}}"#,
)
.unwrap();
assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow);
assert_eq!(subject_for("uhhm"), "portal.access.uhhm.may");
}
#[test]
fn only_refusals_and_mutations_are_worth_publishing() {
assert!(!worth_publishing(Action::Open, true));
assert!(worth_publishing(Action::Open, false));
assert!(worth_publishing(Action::Transition, true));
assert!(!worth_publishing(Action::Read, true));
}
}
+139
View File
@@ -36,6 +36,55 @@ pub struct AggregateSchema {
/// rule has an explicit, auditable opt-out instead of a silent
/// one, and so the next reader knows where the answers go.
pub attended_by: Option<String>,
/// The mail a record's arrival in a state sends (see `mail.rs`):
/// declared on the state in aggregates.yaml, next to the event
/// that enters it, so content decides when a person hears
/// something and the mailer never has to know a site.
pub mail_for_state: HashMap<String, MailSpec>,
/// What a state grants the person whose record entered it: a group
/// they become a member of. A firm approved to file is a member of
/// the approved firms from that moment, and the decision that made
/// it so is the same one the desk already pressed - nobody should
/// have to remember to invite them afterwards.
pub grants_for_state: HashMap<String, String>,
/// The move a clock makes rather than a person. A hearing that gets
/// no answer in six weeks has to go on without one, and nothing in
/// a building is going to press that button.
pub after_for_state: HashMap<String, AfterSpec>,
}
/// A mail to send when a record enters a state. `to` is `submitter`
/// (the record's `email` field, which the lint then requires the
/// forms to collect) or one fixed address. Subject and body are
/// templates: `{field}` for any field of the record, plus `{site}`
/// (the instance's public URL), `{chain}` (the record id), `{email}`,
/// `{bucket}` and `{state}`. The body is markdown; the mailer sends it
/// as plain text with an HTML alternative.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MailSpec {
pub to: String,
pub subject: String,
pub body: String,
}
impl MailSpec {
pub fn to_submitter(&self) -> bool {
self.to == "submitter"
}
}
/// A move made by a clock: `after: { days: 42, to: no_answer }` on the
/// state a record is waiting in. The wait is measured from when the
/// record entered that state, and `to` must be an edge the same schema
/// already declares - a deadline may only do what a person could have
/// done. Days, because every deadline a public body keeps is in days
/// and a shorter unit would invite this to be used as a job queue.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AfterSpec {
pub days: u32,
pub to: String,
}
impl AggregateSchema {
@@ -43,6 +92,18 @@ impl AggregateSchema {
self.event_for_state.contains_key(state)
}
pub fn mail_for(&self, state: &str) -> Option<&MailSpec> {
self.mail_for_state.get(state)
}
pub fn grants_for(&self, state: &str) -> Option<&str> {
self.grants_for_state.get(state).map(String::as_str)
}
pub fn after_for(&self, state: &str) -> Option<&AfterSpec> {
self.after_for_state.get(state)
}
pub fn allowed(&self, state: &str) -> &[String] {
self.transitions
.get(state)
@@ -54,6 +115,12 @@ impl AggregateSchema {
#[derive(Debug, serde::Deserialize)]
struct RawState {
event: String,
#[serde(default)]
mail: Option<MailSpec>,
#[serde(default)]
grants: Option<String>,
#[serde(default)]
after: Option<AfterSpec>,
}
#[derive(Debug, serde::Deserialize)]
@@ -123,6 +190,44 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
}
}
let mut mail_for_state = HashMap::new();
let mut grants_for_state = HashMap::new();
let mut after_for_state = HashMap::new();
for (state, raw_state) in raw_schema.states {
if let Some(group) = raw_state.grants {
if group.trim().is_empty() {
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: grants needs a group");
}
grants_for_state.insert(state.clone(), group);
}
if let Some(after) = raw_state.after {
if after.days == 0 {
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: after.days must be at least one");
}
// A clock may only make a move a person could have
// made, so it is held to the same graph.
if !raw_schema.transitions.get(&state).is_some_and(|tos| tos.contains(&after.to)) {
anyhow::bail!(
"aggregates.yaml: bucket {bucket:?}: state {state:?}: after.to {:?} is not a declared transition from it",
after.to
);
}
after_for_state.insert(state.clone(), after);
}
if let Some(mail) = raw_state.mail {
if mail.subject.trim().is_empty() || mail.body.trim().is_empty() {
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: mail needs a subject and a body");
}
if !mail.to_submitter() && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
anyhow::bail!(
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter` or one address, not {:?}",
mail.to
);
}
mail_for_state.insert(state, mail);
}
}
out.insert(
bucket.clone(),
AggregateSchema {
@@ -132,6 +237,9 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
state_for_event,
transitions: raw_schema.transitions,
attended_by: raw_schema.attended_by,
mail_for_state,
grants_for_state,
after_for_state,
},
);
}
@@ -349,6 +457,37 @@ aggregates:
.unwrap()
}
#[test]
fn a_state_may_carry_the_mail_its_arrival_sends() {
let schemas = parse_aggregates_yaml(
r#"
aggregates:
- bucket: reports
initial: open
states:
open: { event: received, mail: { to: submitter, subject: "Got it, {name}", body: "We read it. {site}/decide?chain={chain}" } }
fixed: { event: fixed, mail: { to: board@example.no, subject: "Fixed: {where}", body: "Done." } }
dropped: { event: dropped }
transitions:
open: [fixed, dropped]
"#,
)
.unwrap();
let s = &schemas["reports"];
assert!(s.mail_for("open").unwrap().to_submitter());
assert_eq!(s.mail_for("fixed").unwrap().to, "board@example.no");
assert!(s.mail_for("dropped").is_none());
for bad in [
"mail: { to: submitter, subject: \"\", body: b }",
"mail: { to: \"the board\", subject: s, body: b }",
"mail: { to: submitter, subject: s, body: b, cc: x }",
] {
let yaml = format!("aggregates:\n - bucket: r\n initial: open\n states:\n open: {{ event: received, {bad} }}\n");
assert!(parse_aggregates_yaml(&yaml).is_err(), "{bad}");
}
}
fn event(seq: u64, event_type: &str) -> Event {
Event {
id: uuid::Uuid::new_v4(),
+12
View File
@@ -92,6 +92,18 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
Some(a) => a,
};
// Content is the source of truth while the window is still
// open: a corrected date or place flows into the record, so the
// followup fires on the schedule the page actually announces.
if answer.state == schema.initial && answer.responses != responses {
let mut refreshed = answer.clone();
refreshed.responses = responses.clone();
if let Some(store) = &store {
store.put(&id, serde_json::to_vec(&refreshed)?.into()).await?;
tracing::info!(question = %q.id, "event record refreshed from content");
}
}
if answer.state == schema.initial && now_ms >= ends {
let target = "awaiting_summary";
let payload = serde_json::json!({ "to": target, "item": id, "by": "portal" });
+67 -14
View File
@@ -28,6 +28,26 @@ pub struct Answer {
pub decided_ms: Option<i64>,
#[serde(default)]
pub decided_by: Option<String>,
/// Replies that came back by mail, oldest first (see
/// `mail::note_for`). A note never moves a record; it is the
/// conversation beside the case, which is the thing a desk could
/// not see before.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<Note>,
}
/// One reply on a record. It lives here rather than with the mail
/// code because it is part of what a record is: the desks render it,
/// and the browser has to know its shape even though it will never
/// speak to a mail server.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Note {
pub id: String,
pub from: String,
#[serde(default)]
pub subject: String,
pub text: String,
pub received_ms: i64,
}
/// Writes a submitted answer into `bucket`, creating it on first use -
@@ -70,6 +90,7 @@ pub async fn store_answer(
state: initial_state,
submitted_ms,
decided_ms: None,
notes: Vec::new(),
decided_by: None,
};
let data = serde_json::to_vec(&answer)?;
@@ -195,14 +216,6 @@ async fn apply_transition(
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
let group = resource
.requires_group
.as_deref()
.ok_or_else(|| ServerFnError::new("resource has no transitions available"))?;
if !user.groups.iter().any(|g| g == group) {
return Err(ServerFnError::new("not authorized"));
}
let bucket = resource
.bucket()
.ok_or_else(|| ServerFnError::new("resource is not kv-backed, has no transitions"))?;
@@ -232,6 +245,19 @@ async fn apply_transition(
))
})?
.clone();
// The policy holds exactly the buttons this desk offers to this
// group, keyed on (from, to); it is the one check.
let decision = crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&crate::access::Principal::from_user(Some(user)),
crate::access::Action::Transition,
&crate::access::Resource::Bucket { name: bucket.to_string() },
serde_json::json!({ "from": answer.state, "to": item.to }),
);
if !decision.allow {
return Err(ServerFnError::new("not authorized"));
}
let decided_ms = chrono::Utc::now().timestamp_millis();
let decision_payload_for_event =
@@ -263,6 +289,8 @@ async fn apply_transition(
.put(&item.item_id, data.into())
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
crate::people::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
// The decision extends the DAG as a child of the answer's own
// submission hash, published like any other answer.
@@ -329,12 +357,33 @@ pub async fn self_transition_answer(
let mut answer: Answer =
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
let stored_email = answer.responses.get("email").and_then(|v| v.as_str());
if stored_email != Some(email.as_str()) {
return Err(ServerFnError::new("not found"));
}
if answer.state != OPEN_STATE {
return Err(ServerFnError::new("already processed"));
// The link is the credential and the email its second factor; the
// policy compares them and knows the one move a submitter may make.
// Refusal reads "not found" so the endpoint cannot be used to probe
// which records or emails exist.
let stored_email = answer.responses.get("email").and_then(|v| v.as_str()).unwrap_or_default();
let decision = crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&crate::access::Principal::Submitter { record: item_id.clone(), email: email.clone() },
crate::access::Action::SelfTransition,
&crate::access::Resource::Bucket { name: self_transition.bucket.clone() },
serde_json::json!({ "from": answer.state, "to": self_transition.to, "stored_email": stored_email }),
);
if !decision.allow {
// Told apart by which half of the rule failed. If the record is
// somewhere this button may be fired from, the email was wrong,
// and the answer must not confirm that the record exists. If it
// is not, the case has moved past this move and saying so is no
// longer a way to learn anything about it.
let initial = state
.aggregates
.load()
.get(&self_transition.bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| OPEN_STATE.to_string());
let in_reach = self_transition.from_states(&initial).iter().any(|s| s == &answer.state);
return Err(ServerFnError::new(if in_reach { "not found" } else { "already processed" }));
}
let decided_ms = chrono::Utc::now().timestamp_millis();
@@ -364,6 +413,8 @@ pub async fn self_transition_answer(
.put(&item_id, data.into())
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
crate::mail::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
crate::people::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
let parent_hashes = vec![item_id.clone()];
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
@@ -403,6 +454,7 @@ mod tests {
submitted_ms: 1_700_000_000_000,
decided_ms: None,
decided_by: None,
notes: Vec::new(),
},
Answer {
id: "def456".to_string(),
@@ -413,6 +465,7 @@ mod tests {
submitted_ms: 1_700_000_001_000,
decided_ms: Some(1_700_000_002_000),
decided_by: Some("bl".to_string()),
notes: Vec::new(),
},
];
let value = serde_json::Value::Array(
+658 -74
View File
File diff suppressed because it is too large Load Diff
-158
View File
@@ -1,158 +0,0 @@
//! Headless schema-check binary - loads content and `aggregates.yaml`
//! (from a Gitea repo URL or a local directory) and validates them
//! exactly the way `content::watch_for_reload`/`main.rs`'s boot path
//! do, with no NATS, OIDC, web server, or JetStream connection
//! involved. Built once by portal's own deploy workflow and run
//! directly by `questions`' own CI (same bare-metal runner/host,
//! published to a stable path - no artifact download needed), rather
//! than compiled there - keeps that repo's CI coupling to "run a
//! static binary," not "build a Rust workspace."
#![cfg(feature = "ssr")]
use portal::{aggregates, content};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let mut repo: Option<String> = None;
let mut branch = "main".to_string();
let mut subdir = "questions".to_string();
let mut path: Option<String> = None;
while let Some(arg) = args.next() {
match arg.as_str() {
"--repo" => repo = args.next(),
"--branch" => branch = args.next().unwrap_or(branch),
"--subdir" => subdir = args.next().unwrap_or(subdir),
"--path" => path = args.next(),
other => {
eprintln!("unknown argument: {other}");
std::process::exit(2);
}
}
}
let (questions, aggregates_map, site) = if let Some(dir) = path {
(load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?, load_site_from_dir(&dir)?)
} else if let Some(repo_url) = repo {
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?;
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?;
// load_site_from_gitea already validates; a missing file is
// the default config, same as at portal boot.
let site = content::load_site_from_gitea(&repo_url, &branch).await?;
(questions, aggregates_map, site)
} else {
eprintln!(
"usage: question-lint --repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>"
);
std::process::exit(2);
};
if let Err(e) = site.validate() {
eprintln!("FAIL: {e}");
std::process::exit(1);
}
let aggregates_map = content::with_builtin_aggregates(aggregates_map);
match content::validate_questions(&questions, &aggregates_map) {
Ok(()) => {
// The runtime's own event desk: a repo announcing pages
// (Question.event) should read portal_events somewhere, or
// "post what happened" tasks pile up unseen. Warn, don't
// fail - the runtime creates the records either way.
if questions.values().any(|q| q.event.is_some())
&& !content::bucket_is_read(&questions, content::EVENTS_BUCKET)
{
eprintln!(
"WARN: pages carry `event:` but no kv resource reads bucket {:?} - add a desk so summaries get posted",
content::EVENTS_BUCKET
);
}
println!(
"OK: {} question(s), {} aggregate(s) valid",
questions.len(),
aggregates_map.len()
);
Ok(())
}
Err(e) => {
eprintln!("FAIL: {e}");
std::process::exit(1);
}
}
}
/// The offline counterpart to `content::load_site_from_gitea` -
/// `site.yaml` lives at the repo root like `aggregates.yaml`, and is
/// just as optional locally: missing means default branding.
fn load_site_from_dir(dir: &str) -> anyhow::Result<content::SiteConfig> {
let site_path = std::path::Path::new(dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("site.yaml");
if !site_path.exists() {
return Ok(content::SiteConfig::default());
}
let raw = std::fs::read_to_string(&site_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", site_path.display()))?;
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
}
/// The offline counterpart to `content::load_aggregates_from_gitea` -
/// `aggregates.yaml` lives at the repo root, one level up from the
/// pages directory `--path` names, so `dir`'s parent is where it's
/// looked for. Missing entirely is not an error here (unlike
/// `--repo` mode, where `aggregates.yaml` is required) - a local
/// checkout being linted may not have one, and every declared
/// transition still gets checked against whatever *is* found; an
/// empty map just means nothing is checked.
fn load_aggregates_from_dir(
dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, aggregates::AggregateSchema>> {
let aggregates_path = std::path::Path::new(dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("aggregates.yaml");
if !aggregates_path.exists() {
return Ok(std::collections::HashMap::new());
}
let raw = std::fs::read_to_string(&aggregates_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", aggregates_path.display()))?;
aggregates::parse_aggregates_yaml(&raw)
}
/// The offline counterpart to `content::load_questions_from_gitea` -
/// the same recursive tree walk and `content::build_questions`
/// pipeline (derived ids, relative refs, sections, followup
/// inference), just reading a local checkout instead of Gitea's API,
/// for linting a branch that hasn't been pushed yet.
fn load_from_dir(
dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, content::Question>> {
let base = std::path::Path::new(dir);
let mut files = Vec::new();
collect_yaml(base, base, &mut files)?;
content::build_questions(&files)
}
fn collect_yaml(
base: &std::path::Path,
dir: &std::path::Path,
out: &mut Vec<(String, String)>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_yaml(base, &path, out)?;
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
let rel = path
.strip_prefix(base)
.expect("walked paths sit under their base")
.to_string_lossy()
.replace('\\', "/");
let raw = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
out.push((rel, raw));
}
}
Ok(())
}
+7
View File
@@ -20,6 +20,11 @@ pub const CHAIN_BUCKET: &str = "portal_chains";
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ChainNode {
pub question_id: String,
/// Which alternative was answered - what decides where the visitor
/// was sent next, and so which followup the chain has earned. Nodes
/// indexed before this field existed carry `None`.
#[serde(default)]
pub alternative: Option<String>,
pub timestamp_ms: i64,
}
@@ -30,6 +35,7 @@ pub async fn record_node(
js: &async_nats::jetstream::Context,
chain_hash: &str,
question_id: &str,
alternative: &str,
timestamp_ms: i64,
) -> anyhow::Result<()> {
let store = match js.get_key_value(CHAIN_BUCKET).await {
@@ -44,6 +50,7 @@ pub async fn record_node(
};
let node = ChainNode {
question_id: question_id.to_string(),
alternative: Some(alternative.to_string()),
timestamp_ms,
};
store.put(chain_hash, serde_json::to_vec(&node)?.into()).await?;
+243
View File
@@ -0,0 +1,243 @@
//! Loading a content repo from Gitea, and the NATS-signalled reload
//! that hot-swaps it (last-good content is kept on any failure). Every
//! fetch goes through `crate::http`: bounded in time and size.
use super::*;
/// Fetches and parses `site.yaml` from the content repo root. A fetch
/// failure (typically 404 - the file is optional) yields the default
/// config; a file that exists but doesn't parse or validate is a real
/// error, surfaced at boot rather than papered over.
#[cfg(feature = "ssr")]
pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<SiteConfig> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await {
Ok(raw) => raw,
Err(e) => {
tracing::info!("no site.yaml in content repo ({e}), using default branding");
return Ok(SiteConfig::default());
}
};
let site: SiteConfig =
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
site.validate()?;
Ok(site)
}
/// Extracts `scheme://host` from a repo's normal browser URL - the
/// Gitea API base every helper in this module builds requests against.
#[cfg(feature = "ssr")]
/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` - the base
/// `site_asset_handler` proxies content-shipped files from.
#[cfg(feature = "ssr")]
pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result<String> {
let (owner, repo) = parse_owner_repo(repo_url)?;
Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?))
}
#[cfg(feature = "ssr")]
pub fn gitea_api_base(repo_url: &str) -> anyhow::Result<String> {
let parsed = url::Url::parse(repo_url)
.map_err(|e| anyhow::anyhow!("parsing repo url {repo_url}: {e}"))?;
Ok(format!(
"{}://{}",
parsed.scheme(),
parsed
.host_str()
.ok_or_else(|| anyhow::anyhow!("no host in repo url {repo_url}"))?
))
}
/// Splits a repo's normal browser URL into `(owner, repo)` - shared by
/// every loader in this module that needs to build a Gitea contents API
/// URL (`load_questions_from_gitea`, `load_aggregates_from_gitea`).
#[cfg(feature = "ssr")]
pub(crate) fn parse_owner_repo(repo_url: &str) -> anyhow::Result<(String, String)> {
let parsed = url::Url::parse(repo_url)
.map_err(|e| anyhow::anyhow!("parsing content repo url {repo_url}: {e}"))?;
let mut segments = parsed
.path_segments()
.ok_or_else(|| anyhow::anyhow!("no path in content repo url {repo_url}"))?;
let owner = segments
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing owner in content repo url {repo_url}"))?
.to_string();
let repo = segments
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing repo name in content repo url {repo_url}"))?
.to_string();
Ok((owner, repo))
}
/// Fetches one file from a Gitea repo's contents API and returns its raw
/// text - the single-file counterpart to `load_questions_from_gitea`'s
/// directory-listing loop, used by `load_aggregates_from_gitea` for the
/// one `aggregates.yaml` file at the repo root.
#[cfg(feature = "ssr")]
pub(crate) async fn fetch_gitea_file(
api_base: &str,
owner: &str,
repo: &str,
branch: &str,
path: &str,
) -> anyhow::Result<String> {
let meta_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{path}?ref={branch}");
let meta_text = crate::http::get_text(&meta_url, crate::http::GITEA_CAP)
.await
.map_err(|e| anyhow::anyhow!("fetching {meta_url}: {e}"))?;
let meta: serde_json::Value = serde_json::from_str(&meta_text)
.map_err(|e| anyhow::anyhow!("parsing contents response from {meta_url}: {e}"))?;
let download_url = meta
.get("download_url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("no download_url for {path}"))?;
crate::http::get_text(download_url, crate::http::GITEA_CAP)
.await
.map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))
}
/// Fetches and parses `aggregates.yaml` from the repo root (sibling to
/// the pages `subdir`, so the "every yaml under subdir is a page"
/// convention needs no exclusion). A malformed file fails here, before
/// ever reaching `AppState`.
#[cfg(feature = "ssr")]
pub async fn load_aggregates_from_gitea(
repo_url: &str,
branch: &str,
) -> anyhow::Result<std::collections::HashMap<String, crate::aggregates::AggregateSchema>> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?;
crate::aggregates::parse_aggregates_yaml(&raw)
}
#[cfg(feature = "ssr")]
pub async fn load_questions_from_gitea(
repo_url: &str,
branch: &str,
subdir: &str,
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
// One recursive git-trees call for the whole repo instead of a
// contents listing per directory - the tree IS the router now, so
// nested files matter.
let tree_url =
format!("{api_base}/api/v1/repos/{owner}/{repo}/git/trees/{branch}?recursive=true");
let listing = crate::http::get_text(&tree_url, crate::http::GITEA_CAP)
.await
.map_err(|e| anyhow::anyhow!("listing {tree_url}: {e}"))?;
let tree: serde_json::Value = serde_json::from_str(&listing)
.map_err(|e| anyhow::anyhow!("parsing tree from {tree_url}: {e}"))?;
if tree.get("truncated").and_then(|v| v.as_bool()) == Some(true) {
anyhow::bail!("git tree listing for {owner}/{repo} was truncated - repo too large");
}
let prefix = format!("{subdir}/");
let mut files = Vec::new();
for entry in tree
.get("tree")
.and_then(|v| v.as_array())
.map(|v| v.as_slice())
.unwrap_or_default()
{
let path = entry.get("path").and_then(|v| v.as_str()).unwrap_or("");
if entry.get("type").and_then(|v| v.as_str()) != Some("blob")
|| !path.starts_with(&prefix)
|| !path.ends_with(".yaml")
{
continue;
}
// Brackets (dynamic pages like `[record].yaml`) must be
// percent-encoded in the raw URL's path.
let encoded = path.replace('[', "%5B").replace(']', "%5D");
let raw_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/raw/{encoded}?ref={branch}");
let raw = crate::http::get_text(&raw_url, crate::http::GITEA_CAP)
.await
.map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))?;
files.push((path[prefix.len()..].to_string(), raw));
}
build_questions(&files)
}
/// Published by the content repo's own CI (after it lints a push) to
/// tell every running instance to pick up the change - a plain fire
/// and forget NATS publish, no payload, matching `events.rs`'s
/// `ANSWERS_SUBJECT` pattern.
#[cfg(feature = "ssr")]
pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload";
/// Runs for the life of the process: re-fetches `repo_url`/`branch` and
/// atomically swaps both `questions` and `aggregates` on every
/// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together,
/// after both have loaded and validated successfully, so a reader never
/// sees pages that reference a half-updated state graph. A fetch/parse
/// failure logs and keeps serving the last-good content rather than
/// clearing it - a bad push to the content repo (which should already
/// have been caught by its own lint step) doesn't take the site down.
#[cfg(feature = "ssr")]
pub async fn watch_for_reload(
nats: async_nats::Client,
repo_url: String,
branch: String,
subdir: String,
questions: std::sync::Arc<arc_swap::ArcSwap<std::collections::HashMap<String, Question>>>,
aggregates: std::sync::Arc<
arc_swap::ArcSwap<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>,
>,
site: std::sync::Arc<arc_swap::ArcSwap<SiteConfig>>,
access: std::sync::Arc<arc_swap::ArcSwap<crate::access::Policy>>,
) {
let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await {
Ok(sub) => sub,
Err(e) => {
tracing::error!(error = %e, "failed to subscribe to content reload subject");
return;
}
};
use futures::StreamExt;
while sub.next().await.is_some() {
let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await {
Ok(loaded) => with_builtin_aggregates(loaded),
Err(e) => {
tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content");
continue;
}
};
// Branding swaps with the same all-or-nothing rule: a
// present-but-broken site.yaml keeps last-good everything
// (load_site distinguishes "absent" - a normal default - from
// "present but invalid").
let loaded_site = match load_site_from_gitea(&repo_url, &branch).await {
Ok(loaded) => loaded,
Err(e) => {
tracing::error!(error = %e, "site.yaml reload failed, keeping last-good content");
continue;
}
};
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
Ok(loaded) => {
if let Err(e) = validate_questions(&loaded, &loaded_aggregates) {
tracing::error!(error = %e, "content reload failed validation, keeping last-good content");
continue;
}
let count = loaded.len();
// The policy is compiled from the content it gates, so
// it swaps in the same breath.
access.store(std::sync::Arc::new(crate::access::Policy::from_content(&loaded, &loaded_aggregates)));
questions.store(std::sync::Arc::new(loaded));
aggregates.store(std::sync::Arc::new(loaded_aggregates));
site.store(std::sync::Arc::new(loaded_site));
tracing::info!(count, "reloaded content");
}
Err(e) => {
tracing::error!(error = %e, "content reload failed, keeping last-good content");
}
}
}
}
+206
View File
@@ -0,0 +1,206 @@
//! The Axum routes that expose content and buckets outside the Leptos
//! app: content-shipped assets proxied same-origin, repo info for the
//! showcase, and the automation read API over KV buckets.
use super::*;
/// A repo-relative asset path portal is willing to proxy from the
/// content repo: plain segments only - no traversal, no leading slash,
/// no scheme - so `/site/<path>` can only ever reach the content repo.
pub fn is_safe_site_path(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
&& path.split('/').all(|seg| {
!seg.is_empty()
&& seg != ".."
&& seg
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
})
}
/// A public Gitea repo's basic info - what `resolve_gitea_repo` returns
/// for the prosekit editor's repo-embed node to render as a static
/// card, baked in once at embed time rather than re-fetched by every
/// reader (an emailed newsletter can't run JS to do that anyway).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GiteaRepoInfo {
pub owner: String,
pub repo: String,
pub description: String,
pub url: String,
}
#[derive(Deserialize)]
#[cfg(feature = "ssr")]
pub struct GiteaRepoQuery {
pub owner: String,
pub repo: String,
}
/// `GET /site/{*path}` - serves a file from the content repo's default
/// branch same-origin, so content-shipped assets (the hero module,
/// its stylesheet) are importable by the page: Gitea's raw endpoint
/// sends no CORS headers, and an ES module import across origins is
/// refused without them. Path is validated to plain segments
/// (`is_safe_site_path`), the response revalidates like /pkg
/// (Cache-Control: no-cache) so a content push shows up on reload.
#[cfg(feature = "ssr")]
pub async fn site_asset_handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
axum::extract::Path(path): axum::extract::Path<String>,
) -> axum::response::Response {
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
if !is_safe_site_path(&path) {
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
}
let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch);
let resp = match crate::http::client().get(&url).send().await.and_then(|r| r.error_for_status()) {
Ok(r) => r,
Err(e) => {
tracing::warn!("site asset {path}: {e}");
return (StatusCode::NOT_FOUND, "no such site asset").into_response();
}
};
let bytes = match crate::http::bytes(resp, crate::http::ASSET_CAP).await {
Ok(b) => axum::body::Bytes::from(b),
Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
};
let mime = match path.rsplit('.').next() {
Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("json") => "application/json",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("avif") => "image/avif",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
};
(
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache")],
bytes,
)
.into_response()
}
/// Looks up `owner/repo` on the same Gitea instance content is loaded
/// from (`AppState.gitea_base`) - a raw Axum handler (mounted at
/// `/gitea-repo` in `main.rs`), not a Leptos server fn, since the
/// caller here is the prosekit editor's own paste-to-embed rule (see
/// `prosekit-editor.js`) doing a plain `fetch`, the same reason
/// `/upload` (`src/upload.rs`) is a raw handler rather than a `#[server]`
/// fn. Keeping this server-resolved (rather than having the browser
/// call Gitea's API directly) is consistent with every other backing
/// store in this app, and sidesteps needing a CORS allowance on Gitea's
/// side just for this. No auth, same as content loading - resolves
/// only what's already public.
#[cfg(feature = "ssr")]
pub async fn gitea_repo_handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
axum::extract::Query(query): axum::extract::Query<GiteaRepoQuery>,
) -> Result<axum::Json<GiteaRepoInfo>, (axum::http::StatusCode, String)> {
let is_safe_segment = |s: &str| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
};
if !is_safe_segment(&query.owner) || !is_safe_segment(&query.repo) {
return Err((axum::http::StatusCode::BAD_REQUEST, "invalid owner/repo".to_string()));
}
let GiteaRepoQuery { owner, repo } = query;
let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base);
let body = crate::http::get_text(&api_url, crate::http::GITEA_CAP)
.await
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("fetching {api_url}: {e}")))?;
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
(
axum::http::StatusCode::BAD_GATEWAY,
format!("parsing repo info from {api_url}: {e}"),
)
})?;
Ok(axum::Json(GiteaRepoInfo {
owner: owner.clone(),
repo: repo.clone(),
description: json
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
url: json
.get("html_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{}/{owner}/{repo}", state.gitea_base)),
}))
}
/// Lists every entry in a NATS KV bucket as JSON - a raw Axum handler
/// (mounted at `/automation/kv/{bucket}` in `main.rs`), for backing
/// automations (e.g. an n8n workflow reading `portal_subscribers` to
/// send a newsletter) that aren't a signed-in browser session and so
/// can't go through `resource::get_resource`'s Kanidm-group check.
/// Gated by a single shared bearer token (`AUTOMATION_READ_TOKEN`) -
/// deliberately not per-caller/per-bucket scoped, since every current
/// caller is a trusted internal automation, not a third party. Read
/// only, matching `get_resource`'s own "reads can be public/shared,
/// mutations always need real identity" split - nothing here writes.
#[cfg(feature = "ssr")]
pub async fn automation_kv_handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
axum::extract::Path(bucket): axum::extract::Path<String>,
headers: axum::http::HeaderMap,
) -> Result<axum::Json<serde_json::Value>, (axum::http::StatusCode, String)> {
let expected = std::env::var("AUTOMATION_READ_TOKEN").unwrap_or_default();
let presented = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("");
if expected.is_empty() || presented != expected {
return Err((axum::http::StatusCode::UNAUTHORIZED, "unauthorized".to_string()));
}
// The token authenticates the automation service; what it may read
// is the policy's to say, like every other principal.
let decision = crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&crate::access::Principal::Service { name: "automation".to_string() },
crate::access::Action::AutomationRead,
&crate::access::Resource::Bucket { name: bucket.clone() },
serde_json::json!({}),
);
if !decision.allow {
return Err((axum::http::StatusCode::FORBIDDEN, "not authorized".to_string()));
}
let store = state
.jetstream
.get_key_value(&bucket)
.await
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("bucket unavailable: {e}")))?;
use futures::TryStreamExt;
let keys: Vec<String> = store
.keys()
.await
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, e.to_string()))?
.try_collect()
.await
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, e.to_string()))?;
let mut items = Vec::new();
for key in keys {
if let Ok(Some(bytes)) = store.get(&key).await {
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
items.push(value);
}
}
}
Ok(axum::Json(serde_json::Value::Array(items)))
}
+120
View File
@@ -0,0 +1,120 @@
//! Inline markdown for descriptions: links on a short allow-list of
//! schemes, images gated and framed, with layout hints read from the
//! image title. Never raw HTML.
/// Descriptions are inline markdown: `[text](https://…)` links,
/// `*emphasis*`, `**strong**`, `` `code` ``. Block structure is
/// flattened (a description is one paragraph) and raw HTML in the
/// source is dropped, so content can link out without being able to
/// inject markup. Link targets are limited to https, mailto and
/// site-relative paths; anything else renders as plain text.
pub fn render_inline_markdown(text: &str) -> String {
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
let parser = Parser::new_ext(text, Options::empty());
let mut in_link = 0usize;
let mut in_dropped_image = 0usize;
let filtered = parser.filter_map(|event| match event {
Event::Html(_) | Event::InlineHtml(_) => None,
Event::Start(Tag::Paragraph) | Event::End(TagEnd::Paragraph) => None,
Event::SoftBreak => Some(Event::Text(" ".into())),
Event::Start(Tag::Link { dest_url, .. })
if !(dest_url.starts_with("https://")
|| dest_url.starts_with("mailto:")
|| dest_url.starts_with("tel:")
|| dest_url.starts_with('/')) =>
{
in_link += 1;
None
}
Event::End(TagEnd::Link) if in_link > 0 => {
in_link -= 1;
None
}
// Images take the same gate as links: https or same-origin
// only - no data:, no plain http. Unlike a dropped link (whose
// label is real text worth keeping), a dropped image's alt
// text is a caption for something that isn't there - swallow it.
Event::Start(Tag::Image { dest_url, .. })
if !(dest_url.starts_with("https://") || dest_url.starts_with('/')) =>
{
in_dropped_image += 1;
None
}
Event::End(TagEnd::Image) if in_dropped_image > 0 => {
in_dropped_image -= 1;
None
}
Event::Text(_) if in_dropped_image > 0 => None,
other => Some(other),
});
let mut out = String::new();
html::push_html(&mut out, filtered);
apply_image_hints(out.trim())
}
/// Translate a markdown image's title (`![alt](url "right 9rem")`)
/// into layout: `left`/`right` float via a class, a bare number
/// (optionally with `rem`) into a validated `max-width`. Unknown
/// tokens are ignored; the title attribute is dropped either way.
/// Only touches `<img>` tags in our own render output, and only ever
/// emits a numeric max-width - no arbitrary CSS reaches the page.
pub(crate) fn apply_image_hints(html: &str) -> String {
let mut out = String::new();
let mut rest = html;
while let Some(pos) = rest.find("<img ") {
out.push_str(&rest[..pos]);
let after = &rest[pos..];
let end = after.find('>').map(|e| e + 1).unwrap_or(after.len());
out.push_str(&rewrite_img_tag(&after[..end]));
rest = &after[end..];
}
out.push_str(rest);
out
}
pub(crate) fn rewrite_img_tag(tag: &str) -> String {
// Pull the title value, if any.
let title = tag
.find("title=\"")
.map(|i| &tag[i + 7..])
.and_then(|r| r.find('"').map(|e| &r[..e]))
.unwrap_or("");
let mut class = String::new();
let mut max_rem: Option<f32> = None;
for tok in title.split_whitespace() {
match tok {
"left" => class = "md-float-left".into(),
"right" => class = "md-float-right".into(),
other => {
if let Ok(n) = other.trim_end_matches("rem").parse::<f32>() {
if n > 0.0 && n <= 60.0 {
max_rem = Some(n);
}
}
}
}
}
// Strip the title attribute from the tag.
let mut cleaned = tag.to_string();
if let Some(i) = cleaned.find(" title=\"") {
if let Some(e) = cleaned[i + 8..].find('"') {
cleaned.replace_range(i..i + 8 + e + 1, "");
}
}
// Inject class/style right after `<img`.
let mut attrs = String::new();
if !class.is_empty() {
attrs.push_str(&format!(" class=\"{class}\""));
}
if let Some(n) = max_rem {
attrs.push_str(&format!(" style=\"max-width:{n}rem\""));
}
if attrs.is_empty() {
return cleaned;
}
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
}
File diff suppressed because it is too large Load Diff
+458
View File
@@ -0,0 +1,458 @@
//! What a content repo must satisfy before it serves: every action
//! lands on a page, every transition is one the state machine allows,
//! every recorded bucket is read back somewhere, and the built-in
//! aggregates the runtime owns are always present.
#[allow(unused_imports)] // the browser build sees fewer items
use super::*;
/// A relay must be a ws:// or wss:// URL - shared between site.yaml's
/// hero and `Requirement.relay` validation.
#[cfg(feature = "ssr")]
pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> {
let parsed = url::Url::parse(relay)
.map_err(|e| anyhow::anyhow!("relay {relay:?} is not a valid URL: {e}"))?;
if !matches!(parsed.scheme(), "ws" | "wss") {
anyhow::bail!("relay {relay:?} must use the ws:// or wss:// scheme");
}
Ok(())
}
/// Validates every declared transition target (`SelfTransition.to`,
/// `ResourceSpec.transitions[].to`) against `aggregates` - the real
/// state graph loaded from `aggregates.yaml` - for every bucket that
/// has one declared. A bucket with no entry in `aggregates` is left
/// alone entirely (no validation applied): not every bucket needs to
/// be event-sourced to keep working. Called on every content
/// load/reload (`watch_for_reload`, `main.rs`'s boot path) and by the
/// standalone `question-lint` binary, so a YAML typo becomes a caught,
/// logged rejection instead of a silently-accepted, later-broken
/// string.
/// Bucket the runtime keeps for announced pages (`Question.event`):
/// one record per event question, `announced` while the window is
/// open, `awaiting_summary` once it closes - the "post what happened"
/// task a desk reading this bucket picks up - and `summarized` when an
/// owner marks it done. Declared here, not in content, because the
/// runtime's sweeper (`announce.rs`) is what moves the records.
pub const EVENTS_BUCKET: &str = "portal_events";
#[cfg(feature = "ssr")]
const BUILTIN_AGGREGATES_YAML: &str = "\
aggregates:
- bucket: portal_events
initial: announced
states:
announced: { event: announced }
awaiting_summary: { event: ended }
summarized: { event: summarized }
transitions:
announced: [awaiting_summary, summarized]
awaiting_summary: [summarized]
- bucket: portal_invites
attended_by: the inviting group's own desk, or the link in the invite event
initial: open
states:
open: { event: invited }
done: { event: done }
transitions:
open: [done]
";
/// Adds the runtime's own state graphs to a content repo's. Content
/// wins if it declares the same bucket (say, to add states).
#[cfg(feature = "ssr")]
pub fn with_builtin_aggregates(
mut aggregates: std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
) -> std::collections::HashMap<String, crate::aggregates::AggregateSchema> {
let builtin = crate::aggregates::parse_aggregates_yaml(BUILTIN_AGGREGATES_YAML)
.expect("builtin aggregates yaml is valid");
for (bucket, schema) in builtin {
aggregates.entry(bucket).or_insert(schema);
}
aggregates
}
/// True when some kv resource in the content reads `bucket` - the
/// attended-bucket check, exposed so the lint can warn about the
/// runtime's own `portal_events` (a warning, not a failure: a repo
/// with no event pages has nothing to attend).
#[cfg(feature = "ssr")]
pub fn bucket_is_read(questions: &std::collections::HashMap<String, Question>, bucket: &str) -> bool {
questions.values().any(|q| {
q.alternatives.iter().any(|a| {
a.features
.iter()
.any(|f| f.resource.as_ref().and_then(|r| r.bucket()).is_some_and(|b| b == bucket))
})
})
}
#[cfg(feature = "ssr")]
pub fn validate_questions(
questions: &std::collections::HashMap<String, Question>,
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
) -> anyhow::Result<()> {
// A mail to the submitter needs an address to go to: every form
// that records into that bucket has to collect a required email.
for (bucket, schema) in aggregates {
let submitter_states: Vec<&String> = schema.mail_for_state.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| s).collect();
if submitter_states.is_empty() {
continue;
}
for question in questions.values() {
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
let collects_email = alternative
.features
.iter()
.flat_map(|f| &f.requirements)
.any(|r| r.name == "email" && r.kind == "email" && !r.optional);
if !collects_email {
anyhow::bail!(
"question {:?} alternative {:?}: records into {bucket:?}, whose state(s) {:?} mail the submitter, so it must collect a required `email` field of type email",
question.id, alternative.name, submitter_states
);
}
}
}
}
// A state that grants a group makes an account for the person the
// record is about, so the record has to know who they are. Same
// shape as an invite's own rule, one step further back: the form
// that fills the bucket is where the name and the address have to
// be asked for, because by the time a desk presses approve there is
// nobody left to ask.
for (bucket, schema) in aggregates {
let mut granting: Vec<&String> = schema.grants_for_state.keys().collect();
if granting.is_empty() {
continue;
}
granting.sort();
for question in questions.values() {
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
let required = |name: &str, kind: &str| {
alternative
.features
.iter()
.flat_map(|f| &f.requirements)
.any(|r| r.name == name && r.kind == kind && !r.optional)
};
if !required("email", "email") || !required("name", "text") {
anyhow::bail!(
"question {:?} alternative {:?}: records into {bucket:?}, whose state(s) {:?} grant a group, so it must collect a required `name` and a required `email` field of type email",
question.id, alternative.name, granting
);
}
}
}
}
for question in questions.values() {
if let Some(event) = &question.event {
if question.is_dynamic() {
anyhow::bail!("question {:?}: a dynamic page can't carry an event", question.id);
}
if let Err(e) = event.window_ms() {
anyhow::bail!("question {:?}: event: {e}", question.id);
}
}
if let Some(place) = &question.place {
if let Err(e) = place.validate() {
anyhow::bail!("question {:?}: place: {e}", question.id);
}
}
if let Some(target) = &question.requires_chain {
if !questions.contains_key(target) {
anyhow::bail!(
"question {:?}: requires_chain {:?} does not match any declared question id",
question.id,
target
);
}
}
for alternative in &question.alternatives {
// A dangling action is a literal dead end: the submit
// button navigates to "Nothing here".
if let Some(action) = &alternative.action {
if action.contains('{') {
// Placeholders are filled at submit time; the
// shape must land on a dynamic page once filled.
let probe = template_action(action, &serde_json::json!({}));
let filled = {
let mut a = action.clone();
while let (Some(i), Some(j)) = (a.find('{'), a.find('}')) {
a.replace_range(i..=j, "x");
}
a
};
let _ = probe;
// Match the pattern itself: resolve_question would
// hand back a clone whose id is already concrete.
let lands = questions
.values()
.any(|q| q.is_dynamic() && path_matches(&q.id, &filled).is_some());
if lands {
continue;
}
anyhow::bail!(
"question {:?} alternative {:?}: templated action {:?} must land on a dynamic page ([name].yaml)",
question.id, alternative.name, action
);
}
match questions.get(action) {
None => anyhow::bail!(
"question {:?} alternative {:?}: action {:?} does not match any declared question id",
question.id, alternative.name, action
),
// A dynamic page needs a concrete segment value to
// be a URL - a submit button can't supply one.
Some(target) if target.is_dynamic() => anyhow::bail!(
"question {:?} alternative {:?}: action {:?} targets a dynamic page - actions must name a concrete question",
question.id, alternative.name, action
),
Some(_) => {}
}
}
if let Some(invite) = &alternative.invite {
if question.qualifies.is_none() {
anyhow::bail!(
"question {:?} alternative {:?}: an invite into {:?} must sit on a page gated by `qualifies` - that group is who may extend it",
question.id, alternative.name, invite.group
);
}
let has = |name: &str| {
alternative
.features
.iter()
.flat_map(|f| &f.requirements)
.any(|r| r.name == name && !r.optional)
};
if !has("name") || !has("email") {
anyhow::bail!(
"question {:?} alternative {:?}: an invite must collect required `name` and `email` fields",
question.id, alternative.name
);
}
}
if let Some(st) = &alternative.self_transition {
if let Some(schema) = aggregates.get(&st.bucket) {
if !schema.has_state(&st.to) {
anyhow::bail!(
"question {:?} alternative {:?}: self_transition.to {:?} is not a declared state for bucket {:?}",
question.id, alternative.name, st.to, st.bucket
);
}
for from in &st.from_states(&schema.initial) {
if !schema.has_state(from) {
anyhow::bail!(
"question {:?} alternative {:?}: self_transition.from {:?} is not a declared state for bucket {:?}",
question.id, alternative.name, from, st.bucket
);
}
// The policy would otherwise permit a move the
// engine refuses: the reader of the content
// should hear about that here, not from a
// person who clicked the button.
if !schema.allowed(from).iter().any(|s| s == &st.to) {
anyhow::bail!(
"question {:?} alternative {:?}: self_transition {:?} -> {:?} is not a declared edge for bucket {:?}",
question.id, alternative.name, from, st.to, st.bucket
);
}
}
}
}
// Bind targets resolve across the whole alternative, same
// scope as the signal maps the renderer builds.
let sibling_names: std::collections::HashSet<&str> = alternative
.features
.iter()
.flat_map(|f| f.requirements.iter())
.map(|r| r.name.as_str())
.collect();
for feature in &alternative.features {
for requirement in &feature.requirements {
if requirement.kind == "select"
&& requirement.resource.is_none()
&& requirement.options.is_empty()
{
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares neither a resource nor inline options",
question.id, alternative.name, feature.name, requirement.name
);
}
if let Some(relay) = &requirement.relay {
if requirement.kind != "gesture" && requirement.kind != "voice" {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture or voice",
question.id, alternative.name, feature.name, requirement.name, requirement.kind
);
}
validate_relay_url(relay).map_err(|e| anyhow::anyhow!(
"question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}",
question.id, alternative.name, feature.name, requirement.name
))?;
}
if requirement.kind == "gesture"
&& (requirement.resource.is_some() || requirement.bind.is_some())
{
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: gesture - it draws its value, it can't also load one from a resource or bind",
question.id, alternative.name, feature.name, requirement.name
);
}
if requirement.kind == "location" {
if requirement.resource.is_some() || requirement.bind.is_some() {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - the visitor puts the pin, it can't also load one from a resource or bind",
question.id, alternative.name, feature.name, requirement.name
);
}
// `value` opens the map somewhere useful. It is
// the content author's own coordinates, so a
// typo here is a map of the Atlantic.
if let Some(value) = &requirement.value {
let parts: Vec<&str> = value.split(',').map(str::trim).collect();
let ok = (2..=3).contains(&parts.len())
&& parts.iter().all(|p| p.parse::<f64>().is_ok())
&& parts[0].parse::<f64>().is_ok_and(|lat| (-90.0..=90.0).contains(&lat))
&& parts[1].parse::<f64>().is_ok_and(|lon| (-180.0..=180.0).contains(&lon));
if !ok {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - its `value` opens the map and must be `lat,lon` or `lat,lon,zoom`, not {value:?}",
question.id, alternative.name, feature.name, requirement.name
);
}
}
}
// One case belonging to another. The bucket has to
// be one this repo declares, or the reference could
// never resolve; and it is the only thing `of`
// means, so it belongs to no other type.
if requirement.kind == "record" {
let Some(of) = &requirement.of else {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: record - it must say `of: <bucket>`, the bucket the record it points at lives in",
question.id, alternative.name, feature.name, requirement.name
);
};
if !aggregates.contains_key(of) {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} points at bucket {of:?}, which has no aggregate declared",
question.id, alternative.name, feature.name, requirement.name
);
}
if requirement.resource.is_some() || requirement.bind.is_some() {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: record - the link it came in on carries the id, it can't also load one from a resource or bind",
question.id, alternative.name, feature.name, requirement.name
);
}
} else if requirement.of.is_some() {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} has `of`, which only a type: record field has",
question.id, alternative.name, feature.name, requirement.name
);
}
if let Some(bind) = &requirement.bind {
if bind.field == requirement.name
|| !sibling_names.contains(bind.field.as_str())
{
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} binds to {:?}, which is not another requirement on this alternative",
question.id, alternative.name, feature.name, requirement.name, bind.field
);
}
}
}
let Some(resource) = &feature.resource else {
continue;
};
let Some(bucket) = resource.bucket() else {
continue;
};
let Some(schema) = aggregates.get(bucket) else {
continue;
};
for state in &resource.states {
if !schema.has_state(state) {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: states {:?} is not a declared state for bucket {:?}",
question.id, alternative.name, feature.name, state, bucket
);
}
}
for transition in &resource.transitions {
if !schema.has_state(&transition.from) {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}",
question.id, alternative.name, feature.name, transition.from, bucket
);
}
if !schema.has_state(&transition.to) {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}",
question.id, alternative.name, feature.name, transition.to, bucket
);
}
if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}",
question.id, alternative.name, feature.name, transition.from, transition.to, bucket
);
}
}
}
}
}
// Attended buckets: no publicly collected answer may land somewhere
// nothing reads. Every `record_as` bucket must either be read back
// by some Kv resource in this same content repo (a desk or listing)
// or carry an explicit `attended_by:` annotation in aggregates.yaml
// naming the automation that consumes it. This is a contract, not a
// convention, because convention already failed once: a question
// was dropped and its bucket - answers included - silently fell out
// of every reader's view.
let mut read_buckets = std::collections::HashSet::new();
let note_resource = |spec: &ResourceSpec, set: &mut std::collections::HashSet<String>| {
if let ResourceSource::Kv { bucket } = &spec.source {
set.insert(bucket.clone());
}
};
for question in questions.values() {
for alternative in &question.alternatives {
for feature in &alternative.features {
if let Some(spec) = &feature.resource {
note_resource(spec, &mut read_buckets);
}
for requirement in &feature.requirements {
if let Some(spec) = &requirement.resource {
note_resource(spec, &mut read_buckets);
}
if let Some(bind) = &requirement.bind {
note_resource(&bind.resource, &mut read_buckets);
}
}
}
}
}
for question in questions.values() {
for alternative in &question.alternatives {
if let Some(bucket) = &alternative.record_as {
let attended = read_buckets.contains(bucket)
|| aggregates
.get(bucket)
.is_some_and(|schema| schema.attended_by.is_some());
if !attended {
anyhow::bail!(
"question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it.",
question.id, alternative.name
);
}
}
}
}
Ok(())
}
+196
View File
@@ -0,0 +1,196 @@
//! The moves a clock makes rather than a person.
//!
//! A state in `aggregates.yaml` may carry `after: { days: 42, to:
//! no_answer }`: when a record has waited there that long, portal makes
//! the move itself. Every deadline a public body keeps is of this
//! shape - a hearing goes out and the authority has six weeks to
//! answer, and when it does not the case has to go on without one, with
//! nobody in the building in a position to press that button.
//!
//! A deadline may only do what a person could have done: `after.to` is
//! held to the same declared edge as a desk button (see
//! `parse_aggregates_yaml`), the move goes through the same
//! `aggregates::transition`, the same mail fires, and it lands on
//! `portal.answers.submitted` like any other decision. What it is not
//! is a job queue: the unit is days, the tick is an hour, and a record
//! that has waited long enough is moved once, because after the move it
//! is no longer in a state that has a deadline.
use crate::answers::Answer;
use crate::events::{emit_answer_submitted, AnswerSubmitted};
use crate::server::AppState;
/// Stamped on the record and on the transition event in place of a
/// username, so a desk and an n8n workflow can both tell a decision
/// nobody made from one somebody did.
pub const BY: &str = "deadline";
const DAY_MS: i64 = 24 * 60 * 60 * 1000;
pub async fn run(state: AppState) {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
loop {
tick.tick().await;
if let Err(e) = sweep(&state).await {
tracing::warn!(error = %e, "deadline sweep failed");
}
}
}
/// When a record entered the state it is in now. A record nobody has
/// moved has been waiting since it was sent.
fn waiting_since(answer: &Answer) -> i64 {
answer.decided_ms.unwrap_or(answer.submitted_ms)
}
/// Whether a record in `state` has waited out its deadline by `now_ms`.
pub fn due(schema: &crate::aggregates::AggregateSchema, answer: &Answer, now_ms: i64) -> Option<String> {
let after = schema.after_for(&answer.state)?;
let deadline = waiting_since(answer) + i64::from(after.days) * DAY_MS;
(now_ms >= deadline).then(|| after.to.clone())
}
/// One pass over every bucket that has a deadline on any of its states.
pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
let now_ms = chrono::Utc::now().timestamp_millis();
let aggregates = state.aggregates.load();
for schema in aggregates.values() {
if schema.after_for_state.is_empty() {
continue;
}
// A bucket is created on first write, so one that has never
// been written to is an ordinary empty bucket, not an error.
let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else {
continue;
};
let keys: Vec<String> = {
use futures::TryStreamExt;
match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => continue,
}
};
for id in keys {
let Ok(Some(bytes)) = store.get(&id).await else { continue };
let Ok(mut answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
let Some(target) = due(schema, &answer, now_ms) else { continue };
let payload = serde_json::json!({ "to": target, "item": id, "by": BY });
if let Err(e) = crate::aggregates::transition(
&state.jetstream, schema, &id, &target, payload.clone(), now_ms,
)
.await
{
// A record whose events are gone, or one another writer
// moved between the read and the write: neither is this
// sweep's to fix, and the next tick sees it again.
tracing::warn!(bucket = %schema.bucket, id, target, "a deadline could not be made: {e}");
continue;
}
answer.state = target.clone();
answer.decided_ms = Some(now_ms);
answer.decided_by = Some(BY.to_string());
if let Ok(data) = serde_json::to_vec(&answer) {
let _ = store.put(&id, data.into()).await;
}
crate::mail::on_state(state, &schema.bucket, &id, &target, &answer.responses).await;
crate::people::on_state(state, &schema.bucket, &id, &target, &answer.responses).await;
let parent_hashes = vec![id.clone()];
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
if let Err(e) = emit_answer_submitted(
&state.nats,
&AnswerSubmitted {
chain_hash,
parent_hashes,
question_id: answer.question_id.clone(),
alternative: target.clone(),
responses: payload,
timestamp_ms: now_ms,
},
)
.await
{
tracing::warn!(bucket = %schema.bucket, id, "the deadline was made but not published: {e}");
}
tracing::info!(bucket = %schema.bucket, id, target, "a deadline passed and the case went on");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn schema(yaml: &str) -> crate::aggregates::AggregateSchema {
crate::aggregates::parse_aggregates_yaml(yaml).unwrap().into_values().next().unwrap()
}
const HEARINGS: &str = r#"
aggregates:
- bucket: hearings
initial: open
states:
open: { event: received, after: { days: 42, to: no_answer } }
answered: { event: answered }
no_answer: { event: no_answer }
transitions:
open: [answered, no_answer]
"#;
fn waiting(state: &str, since_ms: i64) -> Answer {
Answer {
id: "r".into(), question_id: "/q".into(), alternative: "a".into(),
responses: serde_json::json!({}), state: state.into(),
submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![],
}
}
#[test]
fn a_deadline_comes_due_the_day_it_says_and_not_before() {
let s = schema(HEARINGS);
let sent = 1_000_000_000_000;
let answer = waiting("open", sent);
assert_eq!(due(&s, &answer, sent), None);
assert_eq!(due(&s, &answer, sent + 41 * DAY_MS), None);
assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), Some("no_answer".into()));
}
// The six weeks run from the last move, not from the day the case
// was opened: a record someone touched has been waiting since then.
#[test]
fn the_wait_is_measured_from_the_last_move() {
let s = schema(HEARINGS);
let sent = 1_000_000_000_000;
let mut answer = waiting("open", sent);
answer.decided_ms = Some(sent + 40 * DAY_MS);
assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), None);
assert_eq!(due(&s, &answer, sent + 82 * DAY_MS), Some("no_answer".into()));
}
#[test]
fn a_state_with_no_deadline_waits_forever() {
let s = schema(HEARINGS);
let answer = waiting("answered", 0);
assert_eq!(due(&s, &answer, i64::MAX / 2), None);
}
// A clock may only make a move a person could have made, so a
// deadline onto an undeclared edge is refused when content loads,
// not when the hour comes round.
#[test]
fn a_deadline_onto_an_undeclared_edge_is_refused_on_load() {
let bad = HEARINGS.replace("to: no_answer }", "to: answered }").replace("open: [answered, no_answer]", "open: [no_answer]");
let err = crate::aggregates::parse_aggregates_yaml(&bad).unwrap_err().to_string();
assert!(err.contains("not a declared transition"), "{err}");
}
#[test]
fn a_deadline_of_no_days_is_refused() {
let bad = HEARINGS.replace("days: 42", "days: 0");
let err = crate::aggregates::parse_aggregates_yaml(&bad).unwrap_err().to_string();
assert!(err.contains("at least one"), "{err}");
}
}
+64
View File
@@ -0,0 +1,64 @@
//! The one outbound HTTP client, bounded. Every server-side fetch -
//! Gitea content at boot and on reload, the raw-asset proxy, the
//! repo-info endpoint, and the content-declared `url` resource - goes
//! through here, so a slow or oversized upstream can cost a request at
//! most `TIMEOUT` and `cap` bytes, never a thread or the heap. Before
//! this module each call site built its own client with no timeout and
//! read whole bodies with `.text()`.
#![cfg(feature = "ssr")]
use std::sync::OnceLock;
use std::time::Duration;
use openidconnect::reqwest;
/// Whole-request budget: connect, headers, and body.
pub const TIMEOUT: Duration = Duration::from_secs(20);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// A content-declared URL resource: JSON for one card.
pub const URL_RESOURCE_CAP: usize = 2 * 1024 * 1024;
/// A Gitea API reply or one content file (a tree listing, a YAML page).
pub const GITEA_CAP: usize = 8 * 1024 * 1024;
/// A content-shipped asset proxied to the browser (an image, a font).
pub const ASSET_CAP: usize = 32 * 1024 * 1024;
pub fn client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.timeout(TIMEOUT)
.redirect(reqwest::redirect::Policy::limited(5))
.user_agent(concat!("portal/", env!("CARGO_PKG_VERSION")))
.build()
.expect("a client with static options always builds")
})
}
/// Reads a body up to `cap` bytes; one byte more is an error, not a
/// truncation, so a caller never parses half a document.
pub async fn bytes(mut response: reqwest::Response, cap: usize) -> anyhow::Result<Vec<u8>> {
let hint = response.content_length().unwrap_or(0) as usize;
if hint > cap {
anyhow::bail!("response of {hint} bytes exceeds the cap of {cap}");
}
let mut out = Vec::with_capacity(hint.min(cap));
while let Some(chunk) = response.chunk().await? {
if out.len() + chunk.len() > cap {
anyhow::bail!("response exceeds the cap of {cap} bytes");
}
out.extend_from_slice(&chunk);
}
Ok(out)
}
pub async fn text(response: reqwest::Response, cap: usize) -> anyhow::Result<String> {
Ok(String::from_utf8(bytes(response, cap).await?)?)
}
/// `GET url`, status checked, body read under `cap`.
pub async fn get_text(url: &str, cap: usize) -> anyhow::Result<String> {
let response = client().get(url).send().await?.error_for_status()?;
text(response, cap).await
}
+103
View File
@@ -0,0 +1,103 @@
//! Chrome-string translations, selected by `site.yaml`'s `lang`.
//!
//! Content carries its own language; this covers only the strings the
//! portal itself speaks around it ("Asked by", the nav lead, the
//! not-found page...). Unknown languages and unknown keys fall back
//! to English, so a typo degrades to the default instead of a blank.
/// Translate `key` for `lang`. `en` is the reference table; every
/// other language falls through to it for keys it doesn't carry.
pub fn t(lang: &str, key: &str) -> &'static str {
if let Some(s) = lookup(lang, key) {
return s;
}
lookup("en", key).unwrap_or(key_missing(key))
}
fn lookup(lang: &str, key: &str) -> Option<&'static str> {
Some(match (lang, key) {
("en", "also_worth_asking") => "Also worth asking",
("en", "asked_by") => "Asked by ",
("en", "contact_them") => "contact them",
("en", "if_stuck") => " if you get stuck.",
("en", "send") => "Send",
("en", "sign_in") => "Sign in",
("en", "nothing_here_yet") => "Nothing here yet.",
("en", "nothing_here") => "Nothing here",
("en", "back_to_start") => "back to the start",
("en", "done") => "Done.",
("en", "follows_answer") => {
"This page follows from an answer you don't seem to carry yet."
}
("en", "owners_only") => {
"This part of the site is for organizational owners — sign in with that account to take a look."
}
("en", "announcements") => "Announcements",
("en", "down_to") => "Back to ",
("en", "the_start") => "the start",
("no", "also_worth_asking") => "Også verdt å spørre",
("no", "asked_by") => "Stilt av ",
("no", "contact_them") => "ta kontakt",
("no", "if_stuck") => " om du står fast.",
("no", "send") => "Send",
("no", "sign_in") => "Logg inn",
("no", "nothing_here_yet") => "Ingenting her ennå.",
("no", "nothing_here") => "Ingenting her",
("no", "back_to_start") => "tilbake til start",
("no", "done") => "Ferdig.",
("no", "follows_answer") => {
"Denne siden følger av et svar du ikke ser ut til å bære ennå."
}
("no", "owners_only") => {
"Denne delen av siden er for organisasjonens eiere — logg inn med den kontoen for å ta en titt."
}
("no", "announcements") => "Kunngjøringer",
("no", "down_to") => "Tilbake til ",
("no", "the_start") => "starten",
_ => return None,
})
}
/// A missing key is a programmer error; render the key itself so it
/// is findable, never a panic in a view.
fn key_missing(key: &str) -> &'static str {
// Leak is bounded: keys are a small fixed set of literals.
Box::leak(key.to_string().into_boxed_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn norwegian_covers_the_english_table() {
for key in [
"also_worth_asking",
"asked_by",
"contact_them",
"if_stuck",
"send",
"sign_in",
"nothing_here_yet",
"nothing_here",
"back_to_start",
"done",
"follows_answer",
"owners_only",
"announcements",
"down_to",
"the_start",
] {
assert!(lookup("en", key).is_some(), "en missing {key}");
assert!(lookup("no", key).is_some(), "no missing {key}");
}
}
#[test]
fn unknown_language_falls_back_to_english() {
assert_eq!(t("de", "sign_in"), "Sign in");
assert_eq!(t("en", "sign_in"), "Sign in");
}
}
+21
View File
@@ -4,6 +4,7 @@ pub mod auth;
pub mod chain;
pub mod content;
pub mod events;
pub mod i18n;
pub mod resource;
#[cfg(feature = "ssr")]
@@ -12,6 +13,26 @@ pub mod aggregates;
#[cfg(feature = "ssr")]
pub mod announce;
#[cfg(feature = "ssr")]
pub mod deadlines;
#[cfg(feature = "ssr")]
pub mod sessions;
#[cfg(feature = "ssr")]
pub mod http;
#[cfg(feature = "ssr")]
pub mod access;
#[cfg(feature = "ssr")]
pub mod people;
#[cfg(feature = "ssr")]
pub mod mail;
pub mod maps;
#[cfg(feature = "ssr")]
pub mod server;
+489
View File
@@ -0,0 +1,489 @@
//! Portal's side of the mail gate. Content decides when a person
//! hears something (`mail:` on a state in aggregates.yaml, and the
//! built-in invite mail); portal renders the message the moment a
//! record enters that state and publishes it on one JetStream subject.
//! gdo (uhhm/gdo) holds a durable consumer on that subject and hands
//! each message to the host's SMTP. Portal never talks SMTP, gdo never
//! reads content, and a message survives either of them being down.
//!
//! The message is the contract between the two, `Outgoing` below,
//! versioned by `v`. Its id is the JetStream message id, so a
//! transition that fires twice (a retry, a reseed) sends once.
#![cfg(feature = "ssr")]
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// The stream both sides create on boot, with the same config, so
/// whichever starts first owns nothing the other lacks.
pub const STREAM: &str = "mail";
pub const SUBJECTS: &str = "portal.mail.>";
pub const SUBJECT: &str = "portal.mail.send";
/// Where a reply to a case mail arrives, published by whatever reads
/// the mailbox (uhhm/gdo over JMAP). Portal turns it into a note on
/// the record it names and nothing else: no state moves on a reply.
pub const RECEIVED_SUBJECT: &str = "portal.mail.received";
/// Bumped when `Outgoing` changes shape; gdo refuses what it does not
/// know rather than guessing.
pub const CONTRACT: u8 = 1;
/// One mail, fully rendered: everything gdo needs and nothing it has
/// to look up.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Outgoing {
pub v: u8,
/// `<bucket>:<record>:<state>` or `invite:<user>:<group>:<ms>` -
/// the dedup key.
pub id: String,
pub site: String,
pub from: String,
#[serde(default)]
pub reply_to: Option<String>,
pub to: String,
pub subject: String,
pub text: String,
pub html: String,
}
/// A reply that came back, already matched to a record by whatever
/// read the mailbox. Portal trusts the match no further than it can
/// check it: the record must exist, the bucket must be one this site
/// has, and the sender must be the address the record itself holds.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Incoming {
pub v: u8,
/// The mail's own id, so the same reply read twice lands once.
pub id: String,
pub bucket: String,
pub record: String,
/// Who sent it, as the mail server saw it.
pub from: String,
#[serde(default)]
pub subject: String,
/// The reply itself, already stripped of quoted history and
/// signatures by the reader; portal stores what it is given.
pub text: String,
pub received_ms: i64,
}
/// What a reply may add to a record, and what it may never do. Returns
/// the note to append, or why the reply is not one.
///
/// The address a reply comes back on is unguessable, so it is proof of
/// having been sent the mail - but a mailbox is a public door, and an
/// address can be forged. The sender must therefore also match the
/// address the record already holds, which is the one portal wrote to
/// in the first place.
pub fn note_for(incoming: &Incoming, answer: &crate::answers::Answer) -> Result<crate::answers::Note, String> {
if incoming.v != CONTRACT {
return Err(format!("reply contract v{} is not v{CONTRACT}", incoming.v));
}
let text = incoming.text.trim();
if text.is_empty() {
return Err("the reply has no text".to_string());
}
let held = answer
.responses
.get("email")
.and_then(|v| v.as_str())
.map(|e| e.trim().to_lowercase())
.filter(|e| !e.is_empty())
.ok_or_else(|| "the record holds no address to compare the sender with".to_string())?;
if address_of(&incoming.from) != held {
return Err(format!("{} is not the address this record was written to", incoming.from));
}
if answer.notes.iter().any(|n| n.id == incoming.id) {
return Err("this reply is already on the record".to_string());
}
Ok(crate::answers::Note {
id: incoming.id.clone(),
from: address_of(&incoming.from),
subject: incoming.subject.trim().to_string(),
text: text.to_string(),
received_ms: incoming.received_ms,
})
}
/// The bare address out of `Name <a@b>` or `a@b`, folded for
/// comparison. Mail addresses are case-insensitive in the part that
/// matters here, and a display name is not identity.
pub fn address_of(from: &str) -> String {
let inner = match (from.rfind('<'), from.rfind('>')) {
(Some(open), Some(close)) if close > open => &from[open + 1..close],
_ => from,
};
inner.trim().to_lowercase()
}
/// Listens for replies and writes each one onto the record it names.
/// A reply is a note and never a decision: nothing here moves a case,
/// so a mailbox - the one door anyone may knock on - cannot drive a
/// state machine even if someone forges their way past the checks.
pub async fn receive(state: crate::server::AppState) {
use futures::StreamExt;
let mut sub = match state.nats.subscribe(RECEIVED_SUBJECT).await {
Ok(sub) => sub,
Err(e) => {
tracing::error!(error = %e, "cannot listen for replies");
return;
}
};
tracing::info!(subject = RECEIVED_SUBJECT, "listening for replies to case mail");
while let Some(msg) = sub.next().await {
let incoming: Incoming = match serde_json::from_slice(&msg.payload) {
Ok(i) => i,
Err(e) => {
tracing::warn!("unreadable reply on {RECEIVED_SUBJECT}: {e}");
continue;
}
};
match note_onto(&state, &incoming).await {
Ok(()) => tracing::info!(bucket = %incoming.bucket, record = %incoming.record, "a reply was added to the record"),
Err(e) => tracing::warn!(bucket = %incoming.bucket, record = %incoming.record, "reply refused: {e}"),
}
}
}
/// The record's own bucket is the only one a reply may touch, and the
/// note is appended under the same read-modify-write the desks use.
async fn note_onto(state: &crate::server::AppState, incoming: &Incoming) -> anyhow::Result<()> {
if !state.aggregates.load().contains_key(&incoming.bucket) && !crate::content::bucket_is_read(&state.questions.load(), &incoming.bucket) {
anyhow::bail!("{} is not a bucket of this site", incoming.bucket);
}
let store = state.jetstream.get_key_value(&incoming.bucket).await?;
let bytes = store
.get(&incoming.record)
.await?
.ok_or_else(|| anyhow::anyhow!("no such record"))?;
let mut answer: crate::answers::Answer = serde_json::from_slice(&bytes)?;
let note = note_for(incoming, &answer).map_err(|e| anyhow::anyhow!(e))?;
answer.notes.push(note);
store.put(&incoming.record, serde_json::to_vec(&answer)?.into()).await?;
Ok(())
}
/// Idempotent, called by portal at boot and by gdo. A week of
/// retention: a mail nobody delivered in a week is stale, not owed.
/// The duplicate window has to fit inside it.
pub async fn ensure_stream(js: &async_nats::jetstream::Context) -> anyhow::Result<async_nats::jetstream::stream::Stream> {
Ok(js
.get_or_create_stream(async_nats::jetstream::stream::Config {
name: STREAM.to_string(),
subjects: vec![SUBJECTS.to_string()],
max_age: Duration::from_secs(7 * 24 * 3600),
duplicate_window: Duration::from_secs(24 * 3600),
..Default::default()
})
.await?)
}
/// `{key}` in a template becomes the variable's value, an unknown key
/// becomes nothing, and a brace that opens no key stays a brace.
pub fn render(template: &str, vars: &serde_json::Map<String, serde_json::Value>) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find('{') {
out.push_str(&rest[..start]);
let after = &rest[start + 1..];
let key_len = after
.char_indices()
.take_while(|(_, c)| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '.')
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
if key_len > 0 && after[key_len..].starts_with('}') {
let key = &after[..key_len];
match vars.get(key) {
Some(serde_json::Value::String(s)) => out.push_str(s),
Some(serde_json::Value::Null) | None => {}
Some(other) => out.push_str(&other.to_string()),
}
rest = &after[key_len + 1..];
} else {
out.push('{');
rest = after;
}
}
out.push_str(rest);
out
}
/// The HTML alternative: the markdown body as a single column with
/// inline styles, which is as much as mail clients agree on. Raw HTML
/// in the body is dropped, the same rule as page descriptions.
pub fn to_html(markdown: &str) -> String {
use pulldown_cmark::{html, Event, Options, Parser};
let parser = Parser::new_ext(markdown, Options::empty()).filter(|e| !matches!(e, Event::Html(_) | Event::InlineHtml(_)));
let mut body = String::new();
html::push_html(&mut body, parser);
format!(
"<!doctype html><html><body style=\"margin:0;padding:1.5em;background:#f4f4f2;color:#1c1c1a;font:16px/1.5 -apple-system,Segoe UI,Helvetica,Arial,sans-serif\"><div style=\"max-width:36em;margin:0 auto;background:#fff;padding:1.5em 2em;border-radius:0.5em\">{body}</div></body></html>"
)
}
fn public_url() -> String {
std::env::var("PUBLIC_URL").unwrap_or_default().trim_end_matches('/').to_string()
}
fn site_name(site: &crate::content::SiteConfig) -> String {
site.title.clone().unwrap_or_else(|| option_env!("SITE_NAME").unwrap_or("portal").to_string())
}
/// The variables a state mail may use: every field of the record,
/// then the runtime's own.
pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
let mut vars = serde_json::Map::new();
if let Some(fields) = responses.as_object() {
for (k, v) in fields {
vars.insert(k.clone(), match v {
serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
serde_json::Value::Null => serde_json::Value::String(String::new()),
other => serde_json::Value::String(other.to_string()),
});
}
}
let email = responses.get("email").and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
vars.insert("email".into(), email.into());
vars.insert("site".into(), public_url().into());
vars.insert("chain".into(), id.into());
vars.insert("bucket".into(), bucket.into());
vars.insert("state".into(), state.into());
vars
}
async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow::Result<()> {
let mut headers = async_nats::HeaderMap::new();
headers.insert("Nats-Msg-Id", mail.id.as_str());
let bytes = serde_json::to_vec(mail)?;
js.publish_with_headers(SUBJECT, headers, bytes.into()).await?.await?;
Ok(())
}
/// A record entered `state` in `bucket`: send what the schema says,
/// if anything. Best-effort by design - the transition is already
/// committed, and a mail that cannot be rendered is logged, never a
/// failed request.
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value) {
let Some(spec) = state.aggregates.load().get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
return;
};
let site = state.site.load();
let Some(cfg) = &site.mail else {
tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent");
return;
};
let vars = vars_for(bucket, id, to_state, responses);
let to = if spec.to_submitter() {
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
Some(e) => e.to_string(),
None => {
tracing::warn!(bucket, id, to_state, "mail to the submitter, but the record carries no email - not sent");
return;
}
}
} else {
spec.to.clone()
};
let text = render(&spec.body, &vars);
let mail = Outgoing {
v: CONTRACT,
id: format!("{bucket}:{id}:{to_state}"),
site: site_name(&site),
from: cfg.from.clone(),
reply_to: cfg.reply_to.clone(),
to,
subject: render(&spec.subject, &vars),
html: to_html(&text),
text,
};
if let Err(e) = publish(&state.jetstream, &mail).await {
tracing::error!(bucket, id, to_state, "publishing the mail failed: {e}");
}
}
/// The invite mail: the one-time link to the person just onboarded.
/// Built in, since portal knows everything about it; `mail.invite` in
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
/// `{site}`, `{expires}`.
pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) {
let site = state.site.load();
let Some(cfg) = &site.mail else {
tracing::warn!(user = %invited.username, "invite made, but site.yaml has no `mail.from` - the link is on the desk only");
return;
};
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let (subject, body) = match (&cfg.invite, invited.reset_link.is_some(), norwegian) {
(Some(t), _, _) => (t.subject.clone(), t.body.clone()),
(None, true, true) => (
"Du er invitert inn i {group} på {site_name}".to_string(),
"Hei {name},\n\nDu har fått en konto på {site} og en plass i {group}. Velg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}.".to_string(),
),
(None, false, true) => (
"Du har fått plass i {group} på {site_name}".to_string(),
"Hei {name},\n\nKontoen din på {site} har nå plass i {group}. Logg inn som vanlig.".to_string(),
),
(None, true, false) => (
"You are invited into {group} at {site_name}".to_string(),
"Hello {name},\n\nYou have an account at {site} and a place in {group}. Set your password or passkey here; the link works until {expires}:\n\n{link}\n\nYour username is {username}.".to_string(),
),
(None, false, false) => (
"You now have a place in {group} at {site_name}".to_string(),
"Hello {name},\n\nYour account at {site} now has a place in {group}. Sign in as usual.".to_string(),
),
};
let now_ms = chrono::Utc::now().timestamp_millis();
let expires = invited
.link_expires_ms
.and_then(chrono::DateTime::<chrono::Utc>::from_timestamp_millis)
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_default();
let mut vars = serde_json::Map::new();
vars.insert("name".into(), invited.display_name.clone().into());
vars.insert("username".into(), invited.username.clone().into());
vars.insert("email".into(), invited.email.clone().into());
vars.insert("group".into(), invited.group.clone().into());
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
vars.insert("site".into(), public_url().into());
vars.insert("site_name".into(), site_name(&site).into());
vars.insert("expires".into(), expires.into());
let text = render(&body, &vars);
let mail = Outgoing {
v: CONTRACT,
id: format!("invite:{}:{}:{now_ms}", invited.username, invited.group),
site: site_name(&site),
from: cfg.from.clone(),
reply_to: cfg.reply_to.clone(),
to: invited.email.clone(),
subject: render(&subject, &vars),
html: to_html(&text),
text,
};
if let Err(e) = publish(&state.jetstream, &mail).await {
tracing::error!(user = %invited.username, "publishing the invite mail failed: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn templates_take_fields_and_leave_stray_braces_alone() {
let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });
let vars = vars_for("reports", "abc", "fixed", &responses);
assert_eq!(render("Hi {name}, {count} at {site}/decide?chain={chain}&email={email}", &vars), "Hi Kari, 3 at /decide?chain=abc&email=kari@x.no");
assert_eq!(render("{unknown} {hva} {not a key} {", &vars), " {not a key} {");
assert_eq!(vars["bucket"], "reports");
assert_eq!(vars["state"], "fixed");
}
#[test]
fn the_html_alternative_keeps_markdown_and_drops_raw_html() {
let html = to_html("Hello **there**\n\n<script>x()</script>\n\n[link](https://x.no)");
assert!(html.contains("<strong>there</strong>"));
assert!(html.contains("<a href=\"https://x.no\">link</a>"));
assert!(!html.contains("<script>"));
assert!(html.starts_with("<!doctype html>"));
}
#[test]
fn the_contract_round_trips() {
let mail = Outgoing {
v: CONTRACT,
id: "reports:abc:fixed".into(),
site: "Tomter Vel".into(),
from: "Tomter Vel <vel@example.no>".into(),
reply_to: None,
to: "kari@x.no".into(),
subject: "Fixed".into(),
text: "Done.".into(),
html: to_html("Done."),
};
let back: Outgoing = serde_json::from_slice(&serde_json::to_vec(&mail).unwrap()).unwrap();
assert_eq!(back, mail);
// A gdo that predates reply addresses sends none, and reads
// one from a portal that does: the field is optional on the
// wire in both directions.
let older: Outgoing = serde_json::from_str(
r#"{"v":1,"id":"a","site":"s","from":"f@x.no","to":"t@x.no","subject":"s","text":"t","html":"h"}"#,
)
.unwrap();
assert_eq!(older.subject, "s");
assert!(serde_json::to_string(&older).unwrap().contains("\"html\":\"h\""));
}
fn record(email: &str) -> crate::answers::Answer {
crate::answers::Answer {
id: "abc".into(),
question_id: "/report".into(),
alternative: "Report a hazard".into(),
responses: serde_json::json!({ "email": email, "where": "The bend" }),
state: "open".into(),
submitted_ms: 1,
decided_ms: None,
decided_by: None,
notes: Vec::new(),
}
}
fn reply(from: &str, text: &str) -> Incoming {
Incoming {
v: CONTRACT,
id: "mail-1".into(),
bucket: "hazards".into(),
record: "abc".into(),
from: from.into(),
subject: "Re: We have it".into(),
text: text.into(),
received_ms: 2,
}
}
#[test]
fn a_reply_from_the_address_on_the_record_becomes_a_note() {
let note = note_for(&reply("Kari <KARI@x.no>", " It is fixed now. "), &record("kari@x.no")).unwrap();
assert_eq!(note.from, "kari@x.no");
assert_eq!(note.text, "It is fixed now.");
assert_eq!(note.id, "mail-1");
assert_eq!(note.received_ms, 2);
}
#[test]
fn a_reply_from_anyone_else_is_refused() {
// The address a reply arrives on is unguessable, but a From is
// a claim, and a mailbox is a door anyone may knock on.
let err = note_for(&reply("someone@else.no", "Let me in"), &record("kari@x.no")).unwrap_err();
assert!(err.contains("not the address this record was written to"), "{err}");
// Nothing to compare against is also a refusal, not a pass.
let mut anonymous = record("kari@x.no");
anonymous.responses = serde_json::json!({ "where": "The bend" });
assert!(note_for(&reply("kari@x.no", "hi"), &anonymous).unwrap_err().contains("no address"));
}
#[test]
fn an_empty_reply_and_a_repeated_one_add_nothing() {
assert!(note_for(&reply("kari@x.no", " \n "), &record("kari@x.no")).unwrap_err().contains("no text"));
let mut already = record("kari@x.no");
already.notes.push(note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap());
let err = note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap_err();
assert!(err.contains("already on the record"), "{err}");
}
#[test]
fn a_reply_from_a_contract_this_portal_does_not_know_is_refused() {
let mut future = reply("kari@x.no", "hello");
future.v = CONTRACT + 1;
assert!(note_for(&future, &record("kari@x.no")).unwrap_err().contains("contract"));
}
#[test]
fn an_address_is_read_out_of_whatever_the_mail_server_said() {
assert_eq!(address_of("Kari Nordmann <Kari@X.no>"), "kari@x.no");
assert_eq!(address_of(" kari@x.no "), "kari@x.no");
assert_eq!(address_of("<kari@x.no>"), "kari@x.no");
// A display name that looks like an address is not the address.
assert_eq!(address_of("\"admin@bank.no\" <kari@x.no>"), "kari@x.no");
}
}
+114 -6
View File
@@ -10,7 +10,7 @@ async fn main() -> anyhow::Result<()> {
use portal::upload::{self, Garage};
use std::sync::Arc;
use tower_http::services::{ServeDir, ServeFile};
use tower_sessions::{MemoryStore, SessionManagerLayer};
use tower_sessions::SessionManagerLayer;
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
@@ -37,6 +37,7 @@ async fn main() -> anyhow::Result<()> {
content::validate_questions(&questions, &aggregates)?;
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content");
let access = Arc::new(arc_swap::ArcSwap::from_pointee(portal::access::Policy::from_content(&questions, &aggregates)));
let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
let site = Arc::new(arc_swap::ArcSwap::from_pointee(site));
@@ -59,6 +60,14 @@ async fn main() -> anyhow::Result<()> {
// into, and `answers::store_answer` creates it on first use.
let jetstream = async_nats::jetstream::new(nats.clone());
portal::events::store::ensure_stream(&jetstream).await?;
// Outgoing mail waits here for gdo; a mail is never lost to gdo
// being down, and never sent by portal itself.
// Best-effort: a stream that already owns these subjects under an
// older name (a rename mid-upgrade) must not keep a site from
// booting; mail waits until the old stream is removed.
if let Err(e) = portal::mail::ensure_stream(&jetstream).await {
tracing::warn!("the mail stream could not be created: {e} - outgoing mail is off until a stream owns portal.mail.>");
}
let oidc_state = Arc::new(oidc::Oidc::from_env().await?);
@@ -75,7 +84,10 @@ async fn main() -> anyhow::Result<()> {
questions.clone(),
aggregates.clone(),
site.clone(),
access.clone(),
));
// The same decisions, answerable by other apps over NATS.
tokio::spawn(portal::access::serve(nats.clone(), site_name_for_subjects(), access.clone()));
let state = AppState {
leptos_options: leptos_options.clone(),
@@ -84,6 +96,7 @@ async fn main() -> anyhow::Result<()> {
questions,
aggregates,
site,
access,
gitea_base,
content_raw_base,
content_branch: content_branch.clone(),
@@ -94,6 +107,12 @@ async fn main() -> anyhow::Result<()> {
// Announced pages: keep their desk records current, close ended
// windows (see announce.rs).
tokio::spawn(portal::announce::run(state.clone()));
tokio::spawn(portal::deadlines::run(state.clone()));
// Replies to case mail, matched to their record by whatever reads
// the mailbox (uhhm/gdo over JMAP) and turned into notes here. A
// site with no mailbox simply never sees a message on the subject.
tokio::spawn(portal::mail::receive(state.clone()));
// Dev-friendly defaults: in-memory sessions, secure cookies only when
// COOKIE_SECURE=true (set it behind TLS in production) - same
@@ -108,11 +127,50 @@ async fn main() -> anyhow::Result<()> {
let cookie_secure = std::env::var("COOKIE_SECURE")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let session_layer = SessionManagerLayer::new(MemoryStore::default())
// Sessions live in the same JetStream as everything else, so a
// deploy no longer signs out every desk on the site it deploys.
let session_store = portal::sessions::NatsStore::open(&state.jetstream).await?;
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(cookie_secure)
.with_same_site(tower_sessions::cookie::SameSite::Lax)
.with_name("portal_session");
// What a person does costs a little; what a script does costs a
// lot. A burst of 30 with one refilled every two seconds is well
// above anything a visitor filling in a form will reach - a page
// load fetches several resources at once - and well below what it
// takes to be a nuisance. Per caller IP, in memory, no store: this
// is a fence, not an accounting system, and the fence that matters
// for uploads is the policy check on the route itself.
//
// PORTAL_RATE_PER_SECOND / PORTAL_RATE_BURST retune it per site;
// 0 for either turns it off, for an instance behind something that
// already does this.
let rate_seconds: u64 = std::env::var("PORTAL_RATE_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2);
let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30);
let governor = (rate_seconds > 0 && rate_burst > 0).then(|| {
// SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is
// what every one of these instances is behind; it falls back to
// the socket address when neither is set.
let conf = tower_governor::governor::GovernorConfigBuilder::default()
.per_second(rate_seconds)
.burst_size(rate_burst)
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
.finish()
.expect("a rate limit of at least one per second and a burst of at least one");
std::sync::Arc::new(conf)
});
match &governor {
Some(_) => tracing::info!(rate_seconds, rate_burst, "rate limiting per caller IP"),
None => tracing::warn!("rate limiting is off (PORTAL_RATE_SECONDS or PORTAL_RATE_BURST is 0)"),
}
async fn server_fn_handler(
State(state): State<AppState>,
request: Request<Body>,
@@ -138,6 +196,10 @@ async fn main() -> anyhow::Result<()> {
let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root);
let wordmark_path = format!("{}/wordmark.svg", leptos_options.site_root);
let swiper_path = format!("{}/swiper-element-bundle.min.js", leptos_options.site_root);
// Vendored, not loaded from a CDN: a map must not be the one thing
// on the page that phones a stranger.
let maplibre_js = format!("{}/maplibre-gl.js", leptos_options.site_root);
let maplibre_css = format!("{}/maplibre-gl.css", leptos_options.site_root);
let fonts_dir = format!("{}/fonts", leptos_options.site_root);
let app = Router::new()
@@ -145,11 +207,27 @@ async fn main() -> anyhow::Result<()> {
.route("/auth/callback", get(oidc::callback))
.route("/auth/logout", get(oidc::logout))
.route("/api/{*fn_name}", any(server_fn_handler))
.route("/upload", post(upload::upload))
// axum caps request bodies at 2 MB by default, which had made the
// 20 MB upload limit unreachable; raise the fence on this route only,
// with room for the multipart framing.
.route(
"/upload",
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
)
.route(
"/attachment/{bucket}/{record}/{field}",
get(upload::attachment),
)
.route("/gitea-repo", get(content::gitea_repo_handler))
// Content-shipped assets (hero module etc.), same-origin.
.route("/site/{*path}", get(content::site_asset_handler))
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
// Maps, proxied: the browser asks this origin for every tile
// and every still, so nothing third-party loads in a page and
// the token stays here (see maps.rs).
.route("/map/style.json", get(portal::maps::style))
.route("/map/tiles/{z}/{x}/{y}", get(portal::maps::tile))
.route("/map/still/{place}/{size}", get(portal::maps::still))
// no-cache = "revalidate before reuse", not "don't cache":
// pkg files keep the same names across releases (portal.js,
// portal.wasm), and without this browsers heuristically cache
@@ -174,6 +252,8 @@ async fn main() -> anyhow::Result<()> {
"/swiper-element-bundle.min.js",
ServeFile::new(swiper_path),
)
.route_service("/maplibre-gl.js", ServeFile::new(maplibre_js))
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css))
.leptos_routes_with_context(
&state,
routes,
@@ -187,12 +267,28 @@ async fn main() -> anyhow::Result<()> {
},
)
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
.layer(session_layer)
.with_state(state);
.layer(session_layer);
// Outermost, so a caller who is over the limit is turned away
// before a session is loaded or a handler is entered.
let app = match governor {
Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)),
None => app,
}
.with_state(state);
tracing::info!("listening on http://{addr}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app.into_make_service()).await?;
// With connect info, not without: the rate limiter reads the
// caller from `X-Forwarded-For`/`X-Real-IP` and falls back to the
// socket, and without this there is no socket to fall back to. A
// request that arrives without those headers - a health check on
// localhost, anything not through Caddy - then has no key at all,
// and the limiter answers 500 to every one of them.
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
Ok(())
}
@@ -201,3 +297,15 @@ fn main() {
// The browser build is a cdylib; this stub only exists so `cargo check`
// without features still succeeds.
}
#[cfg(feature = "ssr")]
/// The site's name in NATS subjects (`portal.access.<site>.may`):
/// `SITE_NAME`, folded to what a subject token allows.
fn site_name_for_subjects() -> String {
let raw = std::env::var("SITE_NAME").unwrap_or_else(|_| "portal".to_string());
let folded: String = raw
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c.to_ascii_lowercase() } else { '_' })
.collect();
if folded.is_empty() { "portal".to_string() } else { folded }
}
+246
View File
@@ -0,0 +1,246 @@
//! Maps, served entirely from this origin.
//!
//! A `type: location` field lets someone put a pin on a map, and a
//! record that holds one shows where it is. Both need tiles, and tiles
//! come from Mapbox - but the visitor's browser never talks to Mapbox:
//! portal fetches every tile and every static image itself, with the
//! token kept server-side. Nothing third-party loads in the page, so
//! there is no third-party cookie, no consent banner, and no address
//! book of who looked at which hazard.
//!
//! `MAPBOX_TOKEN` unset means the whole feature is off: the field
//! degrades to two number inputs and a stored location renders as its
//! coordinates, the same fail-closed shape uploads take without
//! `GARAGE_*`.
//!
//! Only the proxy handlers are server-side; reading a stored location
//! and addressing its still are needed in the browser too, where the
//! desks and the public lists render.
#[cfg(feature = "ssr")]
use axum::extract::Path;
#[cfg(feature = "ssr")]
use axum::http::{header, StatusCode};
#[cfg(feature = "ssr")]
use axum::response::{IntoResponse, Response};
/// Where the still of one location lives on this origin. The path is
/// the same shape the handler parses back out.
pub fn still_url(lat: f64, lon: f64, zoom: f64, width: u32, height: u32) -> String {
format!("/map/still/{lon:.5},{lat:.5},{zoom:.2}/{width}x{height}")
}
/// A Mapbox style, as `<owner>/<style-id>`. Overridable per instance
/// (`MAPBOX_STYLE`); anything that is not two plain segments is
/// ignored, since it goes into a URL path.
#[cfg(feature = "ssr")]
fn style_id() -> String {
std::env::var("MAPBOX_STYLE")
.ok()
.filter(|s| {
let mut parts = s.split('/');
let (Some(owner), Some(id), None) = (parts.next(), parts.next(), parts.next()) else {
return false;
};
[owner, id].iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'))
})
.unwrap_or_else(|| "mapbox/streets-v12".to_string())
}
/// The instance's Mapbox token, or `None` when maps are off.
#[cfg(feature = "ssr")]
pub fn token() -> Option<String> {
std::env::var("MAPBOX_TOKEN").ok().filter(|t| !t.trim().is_empty())
}
#[cfg(feature = "ssr")]
pub fn enabled() -> bool {
token().is_some()
}
/// Everything MapLibre needs, pointed back at this origin: raster
/// tiles, so there are no sprite or glyph URLs to rewrite and no
/// second round of proxying. The labels are baked into the tiles.
#[cfg(feature = "ssr")]
pub async fn style() -> Response {
if !enabled() {
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
}
let style = serde_json::json!({
"version": 8,
"sources": {
"base": {
"type": "raster",
"tiles": ["/map/tiles/{z}/{x}/{y}"],
"tileSize": 512,
"attribution": "© <a href=\"https://www.mapbox.com/about/maps/\">Mapbox</a> © <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>",
}
},
"layers": [{ "id": "base", "type": "raster", "source": "base" }],
});
(
[(header::CONTENT_TYPE, "application/json"), (header::CACHE_CONTROL, "public, max-age=3600")],
style.to_string(),
)
.into_response()
}
/// One raster tile. The path is three integers and nothing else, so
/// there is no way to steer the upstream request anywhere but a tile.
#[cfg(feature = "ssr")]
pub async fn tile(Path((z, x, y)): Path<(u32, u32, u32)>) -> Response {
let Some(token) = token() else {
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
};
if z > 22 || x >= 1 << z.min(22) || y >= 1 << z.min(22) {
return (StatusCode::BAD_REQUEST, "no such tile").into_response();
}
let url = format!(
"https://api.mapbox.com/styles/v1/{}/tiles/512/{z}/{x}/{y}?access_token={token}",
style_id()
);
fetch(&url, "image/png", "public, max-age=86400").await
}
/// A still map of one point, for a record that holds a location: an
/// image, so a list of twenty of them costs no JavaScript at all.
/// `{place}` is `<lon>,<lat>,<zoom>` and `{size}` is `<w>x<h>`.
#[cfg(feature = "ssr")]
pub async fn still(Path((place, size)): Path<(String, String)>) -> Response {
let Some(token) = token() else {
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
};
let Some((lon, lat, zoom)) = parse_place(&place) else {
return (StatusCode::BAD_REQUEST, "expected <lon>,<lat>,<zoom>").into_response();
};
let Some((w, h)) = parse_size(&size) else {
return (StatusCode::BAD_REQUEST, "expected <width>x<height>, at most 1280").into_response();
};
// The pin is drawn by Mapbox so the image is self-contained; the
// marker colour is portal's ink, not a brand colour.
let url = format!(
"https://api.mapbox.com/styles/v1/{}/static/pin-s+3c3c3a({lon},{lat})/{lon},{lat},{zoom},0/{w}x{h}@2x?access_token={token}",
style_id()
);
fetch(&url, "image/png", "public, max-age=604800").await
}
#[cfg(feature = "ssr")]
async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response {
let resp = match crate::http::client().get(url).send().await.and_then(|r| r.error_for_status()) {
Ok(r) => r,
Err(e) => {
tracing::warn!("map upstream: {e}");
return (StatusCode::BAD_GATEWAY, "the map service did not answer").into_response();
}
};
let upstream = resp
.headers()
.get(openidconnect::reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.filter(|v| v.starts_with("image/"))
.map(str::to_string);
match crate::http::bytes(resp, crate::http::ASSET_CAP).await {
Ok(bytes) => (
[
(header::CONTENT_TYPE, upstream.unwrap_or_else(|| mime.to_string())),
(header::CACHE_CONTROL, cache.to_string()),
],
axum::body::Bytes::from(bytes),
)
.into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
}
}
/// `<lon>,<lat>,<zoom>`, each a plain number in range. Formatted back
/// out rather than passed through, so nothing of the caller's string
/// reaches the upstream URL.
#[cfg(feature = "ssr")]
fn parse_place(place: &str) -> Option<(String, String, String)> {
let mut parts = place.split(',');
let (Some(lon), Some(lat), Some(zoom), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
return None;
};
let lon: f64 = lon.parse().ok()?;
let lat: f64 = lat.parse().ok()?;
let zoom: f64 = zoom.parse().ok()?;
if !(-180.0..=180.0).contains(&lon) || !(-85.0511..=85.0511).contains(&lat) || !(0.0..=22.0).contains(&zoom) {
return None;
}
Some((format!("{lon:.5}"), format!("{lat:.5}"), format!("{zoom:.2}")))
}
#[cfg(feature = "ssr")]
fn parse_size(size: &str) -> Option<(u32, u32)> {
let (w, h) = size.split_once('x')?;
let (w, h): (u32, u32) = (w.parse().ok()?, h.parse().ok()?);
(1..=1280).contains(&w).then_some(())?;
(1..=1280).contains(&h).then_some(())?;
Some((w, h))
}
/// The `{lat, lon, zoom}` a `type: location` field stores, read back
/// out of a record. Anything else is not a location.
pub fn read(value: &serde_json::Value) -> Option<(f64, f64, f64)> {
let obj = value.as_object()?;
let num = |k: &str| obj.get(k).and_then(serde_json::Value::as_f64);
let (lat, lon) = (num("lat")?, num("lon")?);
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
return None;
}
Some((lat, lon, num("zoom").unwrap_or(13.0).clamp(0.0, 22.0)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(feature = "ssr")]
fn a_place_is_three_numbers_in_range_and_is_reformatted() {
assert_eq!(
parse_place("11.123456,59.987654,13.5"),
Some(("11.12346".into(), "59.98765".into(), "13.50".into()))
);
for bad in ["11,59", "11,59,13,0", "200,59,13", "11,95,13", "11,59,30", "11,59,x", "11,59,13?a=b"] {
assert_eq!(parse_place(bad), None, "{bad}");
}
}
#[test]
#[cfg(feature = "ssr")]
fn a_size_is_two_numbers_within_what_the_service_draws() {
assert_eq!(parse_size("600x300"), Some((600, 300)));
for bad in ["600", "0x300", "600x0", "2000x300", "600x2000", "600x300x2", "sixx300"] {
assert_eq!(parse_size(bad), None, "{bad}");
}
}
#[test]
fn a_location_is_a_lat_and_a_lon_and_a_zoom_it_may_leave_out() {
let v = serde_json::json!({ "lat": 59.5, "lon": 11.0, "zoom": 16 });
assert_eq!(read(&v), Some((59.5, 11.0, 16.0)));
assert_eq!(read(&serde_json::json!({ "lat": 59.5, "lon": 11.0 })), Some((59.5, 11.0, 13.0)));
for not in [
serde_json::json!({ "lat": 91.0, "lon": 11.0 }),
serde_json::json!({ "lat": 59.5 }),
serde_json::json!("59.5, 11.0"),
serde_json::Value::Null,
] {
assert_eq!(read(&not), None);
}
}
#[test]
#[cfg(feature = "ssr")]
fn a_style_id_is_two_plain_segments_or_the_default() {
std::env::set_var("MAPBOX_STYLE", "mapbox/outdoors-v12");
assert_eq!(style_id(), "mapbox/outdoors-v12");
for bad in ["../../secrets", "mapbox/streets-v12?x=1", "mapbox", "a/b/c"] {
std::env::set_var("MAPBOX_STYLE", bad);
assert_eq!(style_id(), "mapbox/streets-v12", "{bad}");
}
std::env::remove_var("MAPBOX_STYLE");
}
}
+282
View File
@@ -0,0 +1,282 @@
//! Onboarding a person into Kanidm from a desk, without an admin
//! session: create the account, record their email, add them to the
//! group, and mint a one-time link where they set their own password
//! or passkey. Kanidm has no self-registration by design; a member of
//! the business vouching for each person is the intended shape, and
//! this is that, as a form.
//!
//! Talks to Kanidm's REST API with a service-account token
//! (`KANIDM_API_TOKEN`, read-write) through the bounded client. The
//! service account belongs to `idm_people_on_boarding`, which may
//! create persons and issue first-credential resets and nothing else,
//! so a leaked token cannot touch existing credentials or admins.
#![cfg(feature = "ssr")]
use serde::{Deserialize, Serialize};
pub const INVITES_BUCKET: &str = "portal_invites";
pub const INVITED_SUBJECT: &str = "portal.people.invited";
/// What an invite produced: the account, and the link that sets it up.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Invited {
pub username: String,
pub display_name: String,
pub email: String,
pub group: String,
/// Absent when the person already had an account: they were added
/// to the group and their credentials were left alone.
pub reset_link: Option<String>,
pub link_expires_ms: Option<i64>,
pub created: bool,
}
/// Whether a `GET /v1/person/<name>` answer means the person exists.
/// Kanidm 1.11 answers a missing person with 200 and the body `null`
/// rather than 404, so the status alone would report everyone as
/// existing and no account would ever get created. `None` for an
/// answer that is neither.
fn person_lookup_found(status: u16, body: &str) -> Option<bool> {
match status {
200 => Some(body.trim() != "null" && !body.trim().is_empty()),
404 => Some(false),
_ => None,
}
}
/// The first entry's `name` in a `/v1/raw/search` answer, which is
/// `{"entries": [{"attrs": {"name": ["…"], …}}]}`.
fn name_from_search(body: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
v.get("entries")?.as_array()?.first()?.get("attrs")?.get("name")?.as_array()?.first()?.as_str().map(str::to_string)
}
pub struct Kanidm {
base: String,
token: String,
}
impl Kanidm {
/// `None` when `KANIDM_API_TOKEN` is unset: invites fail closed with
/// a clear message instead of a half-made account.
pub fn from_env() -> Option<Self> {
let base = std::env::var("KANIDM_URL").ok()?.trim_end_matches('/').to_string();
let token = std::env::var("KANIDM_API_TOKEN").ok().filter(|t| !t.is_empty())?;
Some(Kanidm { base, token })
}
async fn call(&self, method: openidconnect::reqwest::Method, path: &str, body: Option<serde_json::Value>) -> anyhow::Result<(u16, String)> {
let mut req = crate::http::client()
.request(method, format!("{}{path}", self.base))
.bearer_auth(&self.token)
.header("content-type", "application/json");
if let Some(body) = body {
req = req.body(serde_json::to_vec(&body)?);
}
let response = req.send().await?;
let status = response.status().as_u16();
let text = crate::http::text(response, crate::http::GITEA_CAP).await?;
Ok((status, text))
}
async fn person_exists(&self, username: &str) -> anyhow::Result<bool> {
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}"), None).await?;
person_lookup_found(status, &text).ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}"))
}
/// The account that already carries this email, if the token may
/// see it (the service account in `idm_people_pii_read`). None
/// when nobody has it, or when the search is not permitted: then
/// the username derived from the email decides, as before.
async fn find_by_mail(&self, email: &str) -> anyhow::Result<Option<String>> {
let (status, text) = self
.call(
openidconnect::reqwest::Method::POST,
"/v1/raw/search",
Some(serde_json::json!({ "filter": { "eq": ["mail", email.trim().to_lowercase()] } })),
)
.await?;
if !(200..300).contains(&status) {
tracing::warn!(status, "kanidm: searching people by mail is not permitted for this token; matching by username instead");
return Ok(None);
}
Ok(name_from_search(&text))
}
/// The whole onboarding, in the order that leaves the least behind
/// if a step fails: find or create, then email, then group, then
/// the link. A person who already has an account, under whatever
/// username, is only added to the group; their credentials are
/// left alone and no link is made.
pub async fn invite(&self, name: &str, email: &str, group: &str) -> anyhow::Result<Invited> {
let existing = self.find_by_mail(email).await?;
let username = existing.clone().unwrap_or_else(|| username_for(email));
let created = if existing.is_some() || self.person_exists(&username).await? {
false
} else {
let (status, text) = self
.call(
openidconnect::reqwest::Method::POST,
"/v1/person",
Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name] } })),
)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: creating {username}: http {status}: {text}");
}
let (status, text) = self
.call(openidconnect::reqwest::Method::PUT, &format!("/v1/person/{username}/_attr/mail"), Some(serde_json::json!([email])))
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: setting mail on {username}: http {status}: {text}");
}
true
};
let (status, text) = self
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
}
let (reset_link, link_expires_ms) = if created {
// 24 hours: the longest Kanidm allows, since the link travels
// by email and is read when the person gets to it.
let (status, text) = self
.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/86400"), None)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
}
#[derive(Deserialize)]
struct Intent {
token: String,
#[serde(default)]
expiry_time: Option<serde_json::Value>,
}
let intent: Intent = serde_json::from_str(&text)?;
let expires = intent.expiry_time.as_ref().and_then(|v| v.as_str()).and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()).map(|t| t.timestamp_millis());
(Some(format!("{}/ui/reset?token={}", self.base, intent.token)), expires)
} else {
(None, None)
};
Ok(Invited {
username,
display_name: name.to_string(),
email: email.to_string(),
group: group.to_string(),
reset_link,
link_expires_ms,
created,
})
}
}
/// A Kanidm account name from an email: the local part, lowercased,
/// anything outside `[a-z0-9._-]` folded to `-`, led by a letter.
pub fn username_for(email: &str) -> String {
let local = email.split('@').next().unwrap_or(email).to_lowercase();
let mut out: String = local
.chars()
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') { c } else { '-' })
.collect();
while out.starts_with(|c: char| !c.is_ascii_lowercase()) && !out.is_empty() {
out.remove(0);
}
if out.is_empty() {
"person".to_string()
} else {
out
}
}
/// The person a record is about, for a state that grants a group.
/// Both are required by the lint of any form that fills such a bucket
/// (see `validate_questions`), so a record missing either is one that
/// predates the rule, and is reported rather than guessed at.
pub fn who_for(responses: &serde_json::Value) -> Option<(String, String)> {
let field = |name: &str| {
responses
.get(name)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
};
let email = field("email").filter(|e| e.contains('@'))?;
Some((field("name")?, email))
}
/// A record entered `state`: if that state grants a group, put the
/// person the record is about in it. The decision the desk already
/// pressed is the whole ceremony - an approval that means membership
/// should not also need somebody to remember to invite them.
///
/// Best-effort, exactly like the mail beside it: the transition is
/// committed either way, and an instance with no Kanidm configured
/// simply grants nothing and says so once per occurrence.
pub async fn on_state(
state: &crate::server::AppState,
bucket: &str,
id: &str,
to_state: &str,
responses: &serde_json::Value,
) {
let Some(group) = state
.aggregates
.load()
.get(bucket)
.and_then(|s| s.grants_for(to_state).map(str::to_string))
else {
return;
};
let Some(kanidm) = Kanidm::from_env() else {
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but invites are not configured on this instance (KANIDM_API_TOKEN) - nobody was added");
return;
};
let Some((name, email)) = who_for(responses) else {
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and email - nobody was added");
return;
};
let invited = match kanidm.invite(&name, &email, &group).await {
Ok(invited) => invited,
Err(e) => {
tracing::error!(bucket, id, to_state, group, "granting the group failed: {e}");
return;
}
};
if let Ok(bytes) = serde_json::to_vec(&invited) {
let _ = state.nats.publish(INVITED_SUBJECT, bytes.into()).await;
}
crate::mail::on_invite(state, &invited).await;
tracing::info!(bucket, id, to_state, group, user = %invited.username, created = invited.created, "the decision granted a group");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn usernames_are_the_email_local_part_made_safe() {
assert_eq!(username_for("jorn@westra-engineering.no"), "jorn");
assert_eq!(username_for("Ole.Nordmann+site@Example.com"), "ole.nordmann-site");
assert_eq!(username_for("42@x.no"), "person");
assert_eq!(username_for("Ærlig@x.no"), "rlig");
}
#[test]
fn a_search_by_mail_yields_the_accounts_own_name() {
assert_eq!(name_from_search(r#"{"entries":[{"attrs":{"name":["ole.n"],"mail":["ole@x.no"]}}]}"#), Some("ole.n".into()));
assert_eq!(name_from_search(r#"{"entries":[]}"#), None);
assert_eq!(name_from_search("null"), None);
assert_eq!(name_from_search("not json"), None);
}
#[test]
fn a_null_person_answer_means_nobody_there() {
assert_eq!(person_lookup_found(200, "null"), Some(false));
assert_eq!(person_lookup_found(200, "null\n"), Some(false));
assert_eq!(person_lookup_found(200, r#"{"attrs":{"name":["bl"]}}"#), Some(true));
assert_eq!(person_lookup_found(404, ""), Some(false));
assert_eq!(person_lookup_found(401, "unauthorized"), None);
}
}
+159 -33
View File
@@ -13,7 +13,7 @@ use leptos::prelude::*;
/// `public: true` nor `requires_group` set is unreachable, not "open"
/// by omission. `params` are the requesting form's current field
/// values (if any) - forwarded as query parameters to a live external
/// fetch (`GiteaStarred`/`GiteaOrgRepos`/`Url`), the mechanism behind
/// fetch (`GiteaStarred`/`GiteaReleases`/`Url`), the mechanism behind
/// "a resource parameterized by other form fields"; a `Kv` resource
/// ignores them entirely, same as today.
#[server(endpoint = "get_resource")]
@@ -38,7 +38,7 @@ pub async fn get_resource(
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
authorize_resource(resource).await?;
authorize_resource(&state, &question_id, &alternative, &feature_name).await?;
fetch_resource_value(&state, resource, &params).await
}
@@ -68,11 +68,21 @@ pub async fn get_requirement_options(
.iter()
.find(|r| r.name == requirement_name)
.ok_or_else(|| ServerFnError::new("unknown requirement"))?;
// Inline options: return them as {id,label} objects, the shape the
// SelectField renders, without touching a resource.
if !requirement.options.is_empty() {
let items: Vec<serde_json::Value> = requirement
.options
.iter()
.map(|o| serde_json::json!({ "id": o, "label": o }))
.collect();
return Ok(serde_json::Value::Array(items));
}
let resource = requirement
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
authorize_resource(resource).await?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
fetch_resource_value(&state, resource, &params).await
}
@@ -103,7 +113,7 @@ pub async fn get_requirement_binding(
.bind
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
authorize_resource(&bind.resource).await?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
fetch_resource_value(&state, &bind.resource, &params).await
}
@@ -142,26 +152,84 @@ fn find_feature(
}
#[cfg(feature = "ssr")]
async fn authorize_resource(resource: &crate::content::ResourceSpec) -> Result<(), ServerFnError> {
async fn authorize_resource(
state: &crate::server::AppState,
question_id: &str,
alternative: &str,
feature: &str,
) -> Result<(), ServerFnError> {
use crate::access::{decide_and_publish, Action, Principal, Resource};
use crate::auth::{User, SESSION_USER_KEY};
if resource.public {
return Ok(());
}
let group = resource
.requires_group
.as_deref()
.ok_or_else(|| ServerFnError::new("resource is not accessible"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("not signed in"))?;
if !user.groups.iter().any(|g| g == group) {
return Err(ServerFnError::new("not authorized"));
.map_err(|e| ServerFnError::new(e.to_string()))?;
let resource = Resource::Feature {
page: question_id.to_string(),
alternative: alternative.to_string(),
feature: feature.to_string(),
};
let decision = decide_and_publish(
&state.nats,
&state.access.load(),
&Principal::from_user(user.as_ref()),
Action::Read,
&resource,
serde_json::json!({}),
);
if decision.allow {
Ok(())
} else if user.is_none() {
Err(ServerFnError::new("not signed in"))
} else {
Err(ServerFnError::new("not authorized"))
}
}
/// Whether a stored record passes the resource's `states` filter. No
/// filter shows everything, which is what every resource written
/// before this existed means. A record with no `state` at all is not
/// an aggregate and is left alone.
#[cfg(feature = "ssr")]
fn shows_state(resource: &crate::content::ResourceSpec, value: &serde_json::Value) -> bool {
if resource.states.is_empty() {
return true;
}
match value.get("state").and_then(|s| s.as_str()) {
Some(state) => resource.states.iter().any(|s| s == state),
None => true,
}
}
/// Whether a stored record passes the resource's `where` filter. The
/// field is looked for in the record's `responses`, which is where a
/// form's answers live, and then on the record itself, so a listing
/// can be narrowed by what was submitted or by what the runtime
/// stamped on it. A missing field never matches: "everything that did
/// not say" is not something a page should be able to ask for by
/// accident.
#[cfg(feature = "ssr")]
fn matches_filter(
resource: &crate::content::ResourceSpec,
value: &serde_json::Value,
params: &std::collections::HashMap<String, String>,
) -> bool {
let Some(filter) = &resource.filter else {
return true;
};
let wanted = crate::content::template_action(&filter.equals, &serde_json::json!(params))
.unwrap_or_else(|| filter.equals.clone());
let found = value
.get("responses")
.and_then(|r| r.get(&filter.field))
.or_else(|| value.get(&filter.field));
match found {
Some(serde_json::Value::String(s)) => s == &wanted,
Some(other) => other.to_string() == wanted,
None => false,
}
Ok(())
}
#[cfg(feature = "ssr")]
@@ -192,7 +260,14 @@ async fn fetch_resource_value(
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("resource key not found"))?;
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?
let value: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
// A state filter holds for one record as well as a
// listing, or naming the key would walk around it.
if !shows_state(resource, &value) || !matches_filter(resource, &value, params) {
return Err(ServerFnError::new("resource key not found"));
}
value
}
(None, Some(store)) => {
use futures::TryStreamExt;
@@ -207,7 +282,9 @@ async fn fetch_resource_value(
for key in keys {
if let Ok(Some(bytes)) = store.get(&key).await {
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
items.push(value);
if shows_state(resource, &value) && matches_filter(resource, &value, params) {
items.push(value);
}
}
}
}
@@ -218,9 +295,6 @@ async fn fetch_resource_value(
ResourceSource::GiteaStarred { username } => {
fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), params).await?
}
ResourceSource::GiteaOrgRepos { org } => {
fetch_gitea_json(state, &format!("/api/v1/orgs/{org}/repos"), params).await?
}
ResourceSource::GiteaReleases { owner, repo } => {
fetch_gitea_json(state, &format!("/api/v1/repos/{owner}/{repo}/releases"), params)
.await?
@@ -250,21 +324,20 @@ async fn fetch_gitea_json(
for (k, v) in params {
url.query_pairs_mut().append_pair(k, v);
}
let client = openidconnect::reqwest::Client::new();
let mut req = client.get(url.clone());
let mut req = crate::http::client().get(url.clone());
if let Ok(token) = std::env::var("GITEA_API_TOKEN") {
if !token.is_empty() {
req = req.header("Authorization", format!("token {token}"));
}
}
let text = req
let response = req
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| ServerFnError::new(format!("fetching {url}: {e}")))?
.text()
.map_err(|e| ServerFnError::new(format!("fetching {url}: {e}")))?;
let text = crate::http::text(response, crate::http::GITEA_CAP)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
.map_err(|e| ServerFnError::new(format!("reading {url}: {e}")))?;
serde_json::from_str(&text).map_err(|e| ServerFnError::new(e.to_string()))
}
@@ -331,16 +404,17 @@ async fn fetch_url_json(
for (k, v) in &leftover {
parsed.query_pairs_mut().append_pair(k, v);
}
let client = openidconnect::reqwest::Client::new();
let text = client
let response = crate::http::client()
.get(parsed.clone())
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| ServerFnError::new(format!("fetching {parsed}: {e}")))?
.text()
.map_err(|e| ServerFnError::new(format!("fetching {parsed}: {e}")))?;
// Content picks the URL; the cap is what keeps a runaway endpoint
// from becoming a runaway allocation here.
let text = crate::http::text(response, crate::http::URL_RESOURCE_CAP)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
.map_err(|e| ServerFnError::new(format!("reading {parsed}: {e}")))?;
serde_json::from_str(&text).map_err(|e| ServerFnError::new(e.to_string()))
}
@@ -446,6 +520,58 @@ pub fn apply_jq(
mod tests {
use super::*;
fn spec(yaml: &str) -> crate::content::ResourceSpec {
serde_yaml::from_str(yaml).unwrap()
}
fn record(state: &str, responses: serde_json::Value) -> serde_json::Value {
serde_json::json!({ "id": "r", "state": state, "responses": responses })
}
// A register that is public by law is public about what was
// decided, not about what was withdrawn.
#[test]
fn a_listing_can_be_narrowed_to_the_states_worth_showing() {
let all = spec("source: { kind: kv, bucket: cases }\npublic: true\n");
let decided = spec("source: { kind: kv, bucket: cases }\npublic: true\nstates: [approved, refused]\n");
for state in ["approved", "refused", "withdrawn", "open"] {
assert!(shows_state(&all, &record(state, serde_json::json!({}))));
}
assert!(shows_state(&decided, &record("approved", serde_json::json!({}))));
assert!(shows_state(&decided, &record("refused", serde_json::json!({}))));
assert!(!shows_state(&decided, &record("withdrawn", serde_json::json!({}))));
// Something that is not an aggregate has no state to filter on
// and is left alone.
assert!(shows_state(&decided, &serde_json::json!({ "name": "a file" })));
}
// The other half of a record belonging to a record: the
// application's page asking for the objections that name it.
#[test]
fn a_listing_can_be_narrowed_to_the_children_of_one_record() {
let children = spec("source: { kind: kv, bucket: objections }\nrequires_group: case_officers\nwhere: { field: case, equals: \"{key}\" }\n");
let params = std::collections::HashMap::from([("key".to_string(), "abc123".to_string())]);
let mine = record("open", serde_json::json!({ "case": "abc123", "your_objection": "the sun" }));
let theirs = record("open", serde_json::json!({ "case": "def456" }));
let neither = record("open", serde_json::json!({ "your_objection": "no case named" }));
assert!(matches_filter(&children, &mine, &params));
assert!(!matches_filter(&children, &theirs, &params));
// A record that did not say is not everyone's child.
assert!(!matches_filter(&children, &neither, &params));
// No filter, no narrowing: every resource written before this
// existed still lists its whole bucket.
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
assert!(matches_filter(&plain, &theirs, &params));
}
#[test]
fn a_filter_can_name_a_value_outright() {
let fixed = spec("source: { kind: kv, bucket: cases }\npublic: true\nwhere: { field: which_authority, equals: The county }\n");
let params = std::collections::HashMap::new();
assert!(matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "The county" })), &params));
assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), &params));
}
/// A canned, Gitea-API-shaped fixture - the same fields the real
/// `/users/{username}/starred` endpoint returns (Gitea 1.27:
/// `stars_count`, not GitHub's `stargazers_count`; `website` is ""
+4
View File
@@ -33,6 +33,10 @@ pub struct AppState {
/// `aggregates` on the same reload. Default = the historical uhhm
/// look.
pub site: Arc<ArcSwap<crate::content::SiteConfig>>,
/// The access policy compiled from the content above (see
/// `access.rs`), swapped with it on every reload. Every server entry
/// point decides through this and nothing else.
pub access: Arc<ArcSwap<crate::access::Policy>>,
/// `scheme://host` of the Gitea instance content is loaded from
/// (see `content::gitea_api_base`) - kept alongside `questions`
/// rather than re-derived per call, since `resolve_gitea_repo` needs
+26 -4
View File
@@ -33,6 +33,13 @@ type OidcClient = CoreClient<
>;
pub struct Oidc {
/// `None` when `KANIDM_URL` is unset: a content-only instance with
/// sign-in disabled - auth routes answer 503, everything public
/// renders as usual.
inner: Option<OidcInner>,
}
struct OidcInner {
client: OidcClient,
http: openidconnect::reqwest::Client,
}
@@ -45,7 +52,13 @@ const REDIRECT_KEY: &str = "oidc_post_login_redirect";
impl Oidc {
/// Discovers the provider and builds the client from environment:
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
/// With `KANIDM_URL` unset, sign-in is disabled instead of fatal -
/// the shape of a public content instance without a review desk.
pub async fn from_env() -> anyhow::Result<Self> {
if std::env::var("KANIDM_URL").is_err() {
tracing::warn!("KANIDM_URL not set - sign-in disabled on this instance");
return Ok(Self { inner: None });
}
let kanidm_url = require_env("KANIDM_URL")?;
let client_id = require_env("OAUTH2_CLIENT_ID")?;
let client_secret = require_env("OAUTH2_CLIENT_SECRET")?;
@@ -75,7 +88,16 @@ impl Oidc {
)
.set_redirect_uri(redirect);
Ok(Self { client, http })
Ok(Self {
inner: Some(OidcInner { client, http }),
})
}
fn configured(&self) -> Result<&OidcInner, HandlerError> {
self.inner.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"sign-in is not configured on this instance".to_string(),
))
}
}
@@ -118,10 +140,10 @@ pub async fn login(
session.insert(REDIRECT_KEY, redirect).await.map_err(internal)?;
}
let oidc = state.oidc.configured()?;
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_state, nonce) = state
.oidc
let (auth_url, csrf_state, nonce) = oidc
.client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
@@ -182,7 +204,7 @@ pub async fn callback(
));
}
let oidc = &state.oidc;
let oidc = state.oidc.configured()?;
let token_response = oidc
.client
.exchange_code(AuthorizationCode::new(params.code))
+122
View File
@@ -0,0 +1,122 @@
//! Sessions in NATS KV.
//!
//! A session used to live in a `MemoryStore`, which meant every
//! restart signed everybody out: a deploy logged out every desk on
//! every site, and the only reason that was bearable is that the desks
//! were three people. This keeps them in the JetStream KV that is
//! already under everything else here - no new service, no new
//! dependency, and the same bucket a second instance of a site would
//! read if there ever is one.
//!
//! Expiry is said twice on purpose. The bucket carries a `max_age`, so
//! NATS collects abandoned sessions without anyone sweeping; and a
//! record's own `expiry_date` is checked on the way out, because the
//! bucket's age is a coarse fence and the record's date is the truth.
#![cfg(feature = "ssr")]
use tower_sessions::session::{Id, Record};
use tower_sessions::session_store::{Error, Result};
/// How long an untouched session survives in the bucket. Comfortably
/// longer than any expiry `SessionManagerLayer` will set, so the
/// record's own date is always what decides; this is only here so a
/// session nobody ever comes back for does not sit in NATS forever.
const MAX_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60 * 24 * 40);
pub const BUCKET: &str = "portal_sessions";
/// A new id is drawn this many times before a collision is reported as
/// a backend error. Two random `i128`s colliding is not a thing that
/// happens; this exists so a bucket misbehaving cannot spin forever.
const ID_ATTEMPTS: usize = 8;
#[derive(Clone)]
pub struct NatsStore {
store: async_nats::jetstream::kv::Store,
}
impl std::fmt::Debug for NatsStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NatsStore").field("bucket", &BUCKET).finish()
}
}
impl NatsStore {
/// Opens the bucket, creating it on first use the way every other
/// bucket here is created.
pub async fn open(js: &async_nats::jetstream::Context) -> anyhow::Result<Self> {
let store = match js.get_key_value(BUCKET).await {
Ok(store) => store,
Err(_) => {
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: BUCKET.to_string(),
max_age: MAX_AGE,
history: 1,
..Default::default()
})
.await?
}
};
Ok(Self { store })
}
fn encode(record: &Record) -> Result<Vec<u8>> {
serde_json::to_vec(record).map_err(|e| Error::Encode(e.to_string()))
}
}
#[async_trait::async_trait]
impl tower_sessions::SessionStore for NatsStore {
/// `create` draws a fresh id until the bucket accepts one as new.
/// `put` would overwrite a stranger's session on a collision;
/// `create` refuses, which is the whole difference.
async fn create(&self, record: &mut Record) -> Result<()> {
for _ in 0..ID_ATTEMPTS {
let bytes = Self::encode(record)?;
match self.store.create(&record.id.to_string(), bytes.into()).await {
Ok(_) => return Ok(()),
Err(_) => record.id = Id::default(),
}
}
Err(Error::Backend(format!(
"could not find an unused session id in {ID_ATTEMPTS} attempts"
)))
}
async fn save(&self, record: &Record) -> Result<()> {
let bytes = Self::encode(record)?;
self.store
.put(&record.id.to_string(), bytes.into())
.await
.map(|_| ())
.map_err(|e| Error::Backend(e.to_string()))
}
async fn load(&self, id: &Id) -> Result<Option<Record>> {
let Some(bytes) = self
.store
.get(&id.to_string())
.await
.map_err(|e| Error::Backend(e.to_string()))?
else {
return Ok(None);
};
let record: Record =
serde_json::from_slice(&bytes).map_err(|e| Error::Decode(e.to_string()))?;
// An expired record is gone as far as anyone asking is
// concerned, whether or not the bucket has collected it yet.
if record.expiry_date <= tower_sessions::cookie::time::OffsetDateTime::now_utc() {
return Ok(None);
}
Ok(Some(record))
}
async fn delete(&self, id: &Id) -> Result<()> {
// Purge, not delete: a signed-out session should leave no
// history in the bucket to read back.
self.store
.purge(&id.to_string())
.await
.map_err(|e| Error::Backend(e.to_string()))
}
}
+285 -36
View File
@@ -15,7 +15,7 @@ use serde::Serialize;
use crate::server::AppState;
const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
#[derive(Clone)]
pub struct Garage {
@@ -57,13 +57,23 @@ pub struct UploadResponse {
key: String,
}
/// POST /upload - streams the first `file` field straight through to
/// Garage and hands back its object key. Server-side hygiene beyond the
/// HTML `accept` hint (a UX-only signal, not a boundary): a hard size
/// cap, and the upload is rejected outright if Garage isn't configured
/// rather than silently succeeding nowhere.
/// POST /upload - takes the `file` field, plus the `question` and
/// `alternative` it belongs to, and hands back its object key.
///
/// The two names are not decoration: they are what makes this an
/// authorized route rather than an open one. An upload is half a
/// submission, so it asks the policy exactly what the submission will
/// ask - `Submit` on that page - and it only accepts a page that
/// actually declares a `type: file` field. Without that, anybody who
/// could reach the host could put twenty megabytes in the bucket as
/// often as they liked, on a site whose forms are public.
///
/// Server-side hygiene beyond the HTML `accept` hint (a UX signal, not
/// a boundary): a hard size cap, and a refusal rather than a silent
/// success when Garage isn't configured.
pub async fn upload(
State(state): State<AppState>,
session: tower_sessions::Session,
mut multipart: Multipart,
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
let garage = state.garage.as_ref().ok_or((
@@ -71,41 +81,280 @@ pub async fn upload(
"uploads are not configured".to_string(),
))?;
while let Some(field) = multipart
let mut question_id = String::new();
let mut alternative = String::new();
let mut file: Option<(String, String, Vec<u8>)> = None;
while let Some(mut field) = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
if field.name() != Some("file") {
continue;
match field.name() {
Some("question") | Some("alternative") => {
let name = field.name().unwrap_or_default().to_string();
let text = field
.text()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
if name == "question" {
question_id = text;
} else {
alternative = text;
}
}
Some("file") => {
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let filename = field.file_name().unwrap_or("upload").to_string();
// Chunk by chunk, refused the moment the cap is
// crossed: an oversized body is never buffered whole
// before being turned away. The route's body limit
// (main.rs) is the outer fence.
let mut bytes: Vec<u8> = Vec::new();
while let Some(chunk) = field
.chunk()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
if bytes.len() + chunk.len() > MAX_UPLOAD_BYTES {
return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string()));
}
bytes.extend_from_slice(&chunk);
}
file = Some((filename, content_type, bytes));
}
_ => continue,
}
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let filename = field.file_name().unwrap_or("upload").to_string();
let bytes = field
.bytes()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
if bytes.len() > MAX_UPLOAD_BYTES {
return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string()));
}
let key = format!("{}-{filename}", uuid::Uuid::new_v4());
garage
.client
.put_object()
.bucket(&garage.bucket)
.key(&key)
.body(ByteStream::from(bytes))
.content_type(content_type)
.send()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("upload failed: {e}")))?;
return Ok(Json(UploadResponse { key }));
}
Err((StatusCode::BAD_REQUEST, "no file field in request".to_string()))
let (filename, content_type, bytes) =
file.ok_or((StatusCode::BAD_REQUEST, "no file field in request".to_string()))?;
// Nothing has been written yet. Everything below decides whether
// anything will be.
if !may_upload(&state, &session, &question_id, &alternative).await {
return Err((StatusCode::FORBIDDEN, "not authorized to upload here".to_string()));
}
let key = format!("{}-{filename}", uuid::Uuid::new_v4());
garage
.client
.put_object()
.bucket(&garage.bucket)
.key(&key)
.body(ByteStream::from(bytes))
.content_type(content_type)
.send()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("upload failed: {e}")))?;
Ok(Json(UploadResponse { key }))
}
/// Whether this caller may attach a file to that page's alternative:
/// the alternative has to exist, has to declare a `type: file` field,
/// and the policy has to let this principal submit that page. The same
/// question `submit_answer` asks, asked before the bytes land rather
/// than after.
async fn may_upload(
state: &AppState,
session: &tower_sessions::Session,
question_id: &str,
alternative: &str,
) -> bool {
use crate::auth::{User, SESSION_USER_KEY};
let questions = state.questions.load();
let Some(page) = page_taking_files(&questions, question_id, alternative) else {
return false;
};
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
let principal = crate::access::Principal::from_user(user.as_ref());
crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&principal,
crate::access::Action::Submit,
&crate::access::Resource::Page { id: page },
serde_json::json!({ "alternative": alternative }),
)
.allow
}
/// The resolved page id, when that page has an alternative of this
/// name that is open and actually asks for a file. `None` for a page
/// that does not exist, an alternative that does not, one that is
/// disabled, or one that asks for no file at all - an upload that
/// names none of the site's own file fields is not an upload this site
/// asked for.
fn page_taking_files(
questions: &std::collections::HashMap<String, crate::content::Question>,
question_id: &str,
alternative: &str,
) -> Option<String> {
let question = crate::content::resolve_question(questions, question_id)?;
question
.alternatives
.iter()
.find(|a| a.name == alternative && !a.disabled)?
.features
.iter()
.any(|f| f.requirements.iter().any(|r| r.kind == "file"))
.then(|| question.id.clone())
}
#[cfg(test)]
mod tests {
use super::*;
fn questions(yaml: &str) -> std::collections::HashMap<String, crate::content::Question> {
crate::content::build_questions(&[("index.yaml".to_string(), yaml.to_string())]).unwrap()
}
const PAGE: &str = "name: Home
alternatives:
- name: Send drawings
record_as: enquiries
features:
- name: Files
requirements:
- { name: drawings, type: file, optional: true }
- name: Just ask
features:
- name: Words
requirements:
- { name: question, type: textarea }
";
// The bytes are only ever written for a page of this site that
// asked for a file. Everything else is somebody using the host as
// a disk.
#[test]
fn an_upload_must_name_a_page_that_asked_for_a_file() {
let qs = questions(PAGE);
assert_eq!(page_taking_files(&qs, "/", "Send drawings"), Some("/".to_string()));
// An alternative on the same page that takes no file.
assert_eq!(page_taking_files(&qs, "/", "Just ask"), None);
// Names that are not there at all.
assert_eq!(page_taking_files(&qs, "/", "Nonexistent"), None);
assert_eq!(page_taking_files(&qs, "/nowhere", "Send drawings"), None);
assert_eq!(page_taking_files(&qs, "", ""), None);
}
#[test]
fn a_closed_alternative_takes_nothing() {
let qs = questions(&PAGE.replace(" record_as: enquiries\n", " record_as: enquiries\n disabled: true\n"));
assert_eq!(page_taking_files(&qs, "/", "Send drawings"), None);
}
}
/// GET /attachment/{bucket}/{record}/{field} - the other half of an
/// upload. A `type: file` field stores an object key, which on its own
/// is a string a desk can read and nobody can open; this streams the
/// object back.
///
/// The key is never the credential. It is not even taken from the
/// caller: the caller names a record, and the key is read out of that
/// record's own field, so a file can only be fetched through a record
/// somebody is allowed to see. Guessing keys gets you nowhere, and a
/// key that leaks out of a desk is not a link to anything.
pub async fn attachment(
State(state): State<AppState>,
session: tower_sessions::Session,
axum::extract::Path((bucket, record, field)): axum::extract::Path<(String, String, String)>,
) -> Result<axum::response::Response, (StatusCode, String)> {
use axum::response::IntoResponse;
let garage = state.garage.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"uploads are not configured".to_string(),
))?;
// Refusals read the same as a miss all the way down, so this
// cannot be used to learn which buckets, records or fields exist.
let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
if !may_read_bucket(&state, &session, &bucket).await {
return Err(missing());
}
let store = state.jetstream.get_key_value(&bucket).await.map_err(|_| missing())?;
let bytes = store.get(&record).await.map_err(|_| missing())?.ok_or_else(missing)?;
let answer: crate::answers::Answer = serde_json::from_slice(&bytes).map_err(|_| missing())?;
let key = answer
.responses
.get(&field)
.and_then(|v| v.as_str())
.filter(|k| !k.is_empty())
.ok_or_else(missing)?;
let object = garage
.client
.get_object()
.bucket(&garage.bucket)
.key(key)
.send()
.await
.map_err(|_| missing())?;
let content_type = object
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let body = object
.body
.collect()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("reading the file failed: {e}")))?
.into_bytes();
// The stored key is `<uuid>-<original name>`; the person who gets
// the file should see the name they sent, not the uuid.
let filename = key.split_once('-').map(|(_, rest)| rest).unwrap_or(key);
let filename = filename.rsplit('/').next().unwrap_or(filename).replace(['"', '\\'], "");
Ok((
[
(axum::http::header::CONTENT_TYPE, content_type),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
),
],
body,
)
.into_response())
}
/// Whether this caller may see this bucket at all: whether any feature
/// in the loaded content lists it and the policy lets them read that
/// feature. The desk that shows the record is what grants the file.
async fn may_read_bucket(
state: &AppState,
session: &tower_sessions::Session,
bucket: &str,
) -> bool {
use crate::auth::{User, SESSION_USER_KEY};
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
let principal = crate::access::Principal::from_user(user.as_ref());
let access = state.access.load();
state.questions.load().values().any(|question| {
question.alternatives.iter().any(|alternative| {
alternative.features.iter().any(|feature| {
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket)
&& access.allows(
&principal,
crate::access::Action::Read,
&crate::access::Resource::Feature {
page: question.id.clone(),
alternative: alternative.name.clone(),
feature: feature.name.clone(),
},
)
})
})
})
}
+370 -17
View File
@@ -99,10 +99,10 @@
--hero-glow: rgba(246, 245, 241, 0.85);
}
/* The wordmark SVG is a hardcoded white stroke (also used raw in
dark contexts elsewhere) - flip it to ink here rather than fork
the asset. */
.wordmark img {
/* The house wordmark SVG is a hardcoded white stroke (also used
raw in dark contexts elsewhere) - flip it to ink here rather
than fork the asset. A content-provided logo keeps its colors. */
.wordmark-house img {
filter: invert(0.92);
}
@@ -147,6 +147,10 @@ a {
main.loading,
main.not-found {
min-height: 60vh;
/* svh so wrapper browsers that resize the webview as their toolbar
animates (Firefox/Chrome iOS) don't reflow this placeholder - the
small viewport is a fixed value. Same ladder as .hero-piece. */
min-height: 60svh;
display: flex;
flex-direction: column;
align-items: center;
@@ -193,6 +197,10 @@ main.not-found {
color: var(--ink-dim);
font-size: 1.05rem;
max-width: 46ch;
/* The measure cap shrinks the box below the copy column; without
auto margins the box left-anchors and its centered text centers
in the wrong frame. */
margin-inline: auto;
}
/* A content-shipped hero module (site.yaml hero.kind: module) draws
@@ -289,6 +297,67 @@ main.not-found {
}
}
/* The way down the tree: one level toward the front door, from any
page. Fixed at the left edge, half-way up, over the margin rather
than the text; small, and half-faded until touched, so it never
covers what a visitor is reading. */
.down-the-tree {
position: fixed;
left: 0.6rem;
top: 50%;
transform: translateY(-50%);
z-index: 20;
width: 2.6rem;
height: 2.6rem;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--card-bg-opaque);
color: var(--ink);
box-shadow: 0 0.2rem 0.8rem var(--card-shadow);
opacity: 0.7;
transition: opacity 120ms, transform 120ms;
}
.down-the-tree:hover,
.down-the-tree:focus-visible {
opacity: 1;
transform: translateY(-50%) translateX(-0.12rem);
}
.down-the-tree svg {
width: 1.3rem;
height: 1.3rem;
}
@media (max-width: 43.75rem) {
.down-the-tree {
left: 0.25rem;
width: 2.2rem;
height: 2.2rem;
opacity: 0.55;
}
}
.down-the-tree-foot {
position: relative;
z-index: 1;
text-align: center;
margin: 2.6rem auto 0;
font-size: 0.95rem;
}
.down-the-tree-foot a {
color: var(--ink);
text-decoration: none;
border-bottom: 0.06rem solid var(--line);
}
.down-the-tree-foot a:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
.question-nav {
display: flex;
flex-wrap: wrap;
@@ -364,8 +433,12 @@ main.not-found {
.alt-image {
display: block;
width: 100%;
max-height: 14rem;
object-fit: cover;
height: auto;
/* Natural aspect, not a cropped band; a very tall image is still
bounded so it can't tower. The deck below overrides for its
fixed-height cards. */
max-height: 32rem;
object-fit: contain;
border-radius: calc(var(--radius) - 0.3rem);
margin-bottom: 1.1rem;
}
@@ -430,10 +503,9 @@ main.not-found {
}
.feature-icon {
float: left;
width: 1.3rem;
height: 1.3rem;
margin: 0.1rem 0.6rem 0.4rem 0;
margin-top: 0.1rem;
background-color: var(--feature-accent, currentColor);
mask-repeat: no-repeat;
mask-size: contain;
@@ -443,20 +515,139 @@ main.not-found {
-webkit-mask-position: center;
}
/* With an icon the feature is two columns: the icon in the first,
everything else - name, description, fields, resources - in the
second, so every line of text shares one left edge instead of
wrapping back under the icon. */
.feature:has(> .feature-icon) {
display: grid;
grid-template-columns: 1.3rem minmax(0, 1fr);
column-gap: 0.7rem;
align-items: start;
}
.feature:has(> .feature-icon) > .feature-icon {
grid-column: 1;
grid-row: 1;
}
.feature:has(> .feature-icon) > :not(.feature-icon) {
grid-column: 2;
}
.feature h3 {
font-size: 1rem;
margin-bottom: 0.15em;
}
.feature h3 a {
color: inherit;
text-decoration: none;
border-bottom: 0.06rem solid var(--line);
/* voice field (voice.js): a record button, a status line, a preview */
.voice-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1rem;
}
.feature h3 a:hover {
.voice-button {
font: inherit;
padding: 0.55rem 1.1rem;
border: 0.06rem solid var(--accent);
border-radius: 2rem;
background: transparent;
color: var(--ink);
cursor: pointer;
}
.voice-button:hover:not(:disabled) {
background: var(--accent);
color: var(--paper);
}
.voice-button.recording {
background: var(--accent);
color: var(--paper);
animation: voice-pulse 1.2s ease-in-out infinite;
}
.voice-button:disabled {
opacity: 0.5;
cursor: default;
}
@keyframes voice-pulse {
50% { opacity: 0.6; }
}
.voice-status {
font-size: 0.85rem;
color: var(--ink-dim);
}
.voice-preview,
.item-card-audio {
display: block;
width: 100%;
max-width: 28rem;
margin-top: 0.4rem;
}
/* display:block above would beat the hidden attribute */
.voice-preview[hidden] {
display: none;
}
/* links inside markdown descriptions */
.alt-description a,
.feature p a {
color: inherit;
text-decoration: none;
border-bottom: 0.06rem solid var(--accent);
}
.alt-description a:hover,
.feature p a:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* images inside markdown descriptions: full-width figures in the
card's flow, framed like the rest of the press sheet */
.alt-description img,
.item-card-description img,
.feature p img {
display: block;
width: 100%;
margin: 0.6rem 0 0.2rem;
border-radius: 0.5rem;
border: 0.06rem solid var(--line);
}
/* Content hint via a markdown image title (`![alt](url "right 9rem")`):
float and shrink so text wraps around a portrait. */
.alt-description img.md-float-left,
.item-card-description img.md-float-left,
.feature p img.md-float-left,
.alt-description img.md-float-right,
.item-card-description img.md-float-right,
.feature p img.md-float-right {
width: auto;
max-width: 45%;
}
.alt-description img.md-float-left,
.item-card-description img.md-float-left,
.feature p img.md-float-left {
float: left;
margin: 0.2rem 1.1rem 0.5rem 0;
}
.alt-description img.md-float-right,
.item-card-description img.md-float-right,
.feature p img.md-float-right {
float: right;
margin: 0.2rem 0 0.5rem 1.1rem;
}
.alt-description code,
.feature p code {
font-size: 0.9em;
}
.feature p {
@@ -565,11 +756,17 @@ textarea:focus {
touch-action: none;
}
/* Results overlay the canvas instead of growing the widget - the
page never jumps when places arrive (or don't). */
.gesture-echoes {
position: absolute;
left: 0.6rem;
right: 0.6rem;
bottom: 0.6rem;
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 0.5rem;
min-height: 3rem;
pointer-events: none;
}
.gesture-echoes:empty {
@@ -584,12 +781,48 @@ textarea:focus {
border-radius: 0.4rem;
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: auto;
box-shadow: 0 0.15rem 0.6rem rgba(0, 0, 0, 0.18);
}
.gesture-echo.shown {
opacity: var(--echo-strength, 1);
}
/* places are pickable: the pick re-derives the input's key */
.gesture-echo {
cursor: pointer;
transition: opacity 0.5s ease, border-color 120ms, transform 120ms;
}
.gesture-echo:hover,
.gesture-echo:focus-visible {
border-color: var(--accent);
outline: none;
}
.gesture-echo.selected {
border-color: var(--accent);
box-shadow: 0 0 0 0.12rem var(--accent);
transform: translateY(-0.1rem);
}
.gesture-empty {
position: absolute;
left: 0;
right: 0;
bottom: 0.7rem;
margin: 0;
text-align: center;
font-size: 0.85rem;
color: var(--ink-dim);
pointer-events: none;
}
.gesture-empty:empty {
display: none;
}
.gesture-status {
position: absolute;
top: 0.55rem;
@@ -721,6 +954,10 @@ textarea:focus {
padding: 0.75rem 1.4rem;
cursor: pointer;
transition: filter 120ms, transform 120ms;
/* A gateway alternative renders this as an <a> (navigation, no POST);
kill the link chrome so it reads as the button it looks like. */
text-decoration: none;
display: inline-block;
}
.alt-submit:hover {
@@ -869,6 +1106,9 @@ textarea:focus {
.item-card-description {
font-size: 0.9rem;
color: var(--ink-dim);
/* The card is a grid with its own gap; the global p bottom margin
would stack on top of it. */
margin: 0;
}
.item-card-fields {
@@ -941,4 +1181,117 @@ textarea:focus {
flex-direction: column;
align-items: flex-start;
}
}
}
/* A `type: location` field: the map fills the field, and the pin is the
centre of it - panning works the same with a finger, a mouse or the
arrow keys, and there is nothing to miss by a few pixels. MapLibre
draws into .map-canvas after hydration, so the box is reserved up
front the way .gesture-wrap reserves its own. */
.map-wrap {
position: relative;
width: 100%;
}
.map-wrap:empty {
aspect-ratio: 3 / 2;
}
.map-canvas {
display: block;
width: 100%;
aspect-ratio: 3 / 2;
border: 0.06rem solid var(--line);
border-radius: 0.2rem;
overflow: hidden;
}
/* The pin sits over the exact centre of the canvas and takes no
pointer events, so every drag goes to the map under it. Its point is
the bottom tip, hence the upward shift of its own height. */
.map-pin {
position: absolute;
top: 50%;
left: 50%;
width: 0.9rem;
height: 0.9rem;
margin: -1.35rem 0 0 -0.45rem;
border: 0.18rem solid var(--ink);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
pointer-events: none;
background: var(--paper);
}
.map-readout {
margin: 0.3rem 0 0;
font-size: 0.85em;
color: var(--ink-dim);
}
/* A stored location in a list: a still image from this origin, never a
live map - twenty rows of WebGL would cost more than the page. */
.map-still {
display: block;
width: 100%;
max-width: 24rem;
height: auto;
border: 0.06rem solid var(--line);
border-radius: 0.2rem;
}
.map-coords {
display: block;
margin-top: 0.2rem;
font-size: 0.85em;
}
.answer-field-map .answer-field-value {
display: block;
}
/* Replies that came back by mail, under the record they belong to.
They move nothing, so there is no control here - only who wrote,
when, and what they said. */
.answer-notes {
list-style: none;
margin: 0.4rem 0 0;
padding: 0 0 0 0.7rem;
border-left: 0.12rem solid var(--line);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.answer-note-who {
font-weight: 600;
}
.answer-note-time {
margin-left: 0.4rem;
font-size: 0.85em;
color: var(--ink-dim);
}
.answer-note-text {
margin: 0.15rem 0 0;
white-space: pre-wrap;
}
/* A page that is about a place: the map sits under the question, at
the width of the text it belongs to. */
.question-place {
margin: 0.8rem 0 0;
}
.question-place figcaption {
margin-top: 0.3rem;
font-size: 0.9em;
color: var(--ink-dim);
}
/* A loading slot holds its line's worth of space and says nothing.
The words that belong here have not arrived yet, and "loading…" is
not one of them. */
.resource-loading {
min-height: 1.4em;
}
+155
View File
@@ -0,0 +1,155 @@
//! The one test that meets a real JetStream: a submission becomes an
//! aggregate, a desk move is accepted, an undeclared move is refused,
//! and the record reads back from its own event log. Everything else
//! in the suite replays in memory; this is what proves the store, the
//! event log and the projection agree with each other.
//!
//! Runs only when `PORTAL_TEST_NATS_URL` points at a JetStream (CI
//! starts a throwaway one, see `.gitea/workflows/test.yml`); without
//! it the test passes as skipped, so `cargo test` stays green offline.
#![cfg(feature = "ssr")]
use portal::aggregates::{self, parse_aggregates_yaml, TransitionError};
use portal::answers::{store_answer, Answer};
use portal::events::store::{ensure_stream, load_events};
use serde_json::json;
#[tokio::test]
async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_engine() {
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
return;
};
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
ensure_stream(&js).await.expect("event stream");
// A bucket of its own per run, so reruns and parallel runs never
// read each other's records.
let bucket = format!("it_{}", chrono::Utc::now().timestamp_millis());
let schemas = parse_aggregates_yaml(&format!(
"aggregates:\n - bucket: {bucket}\n initial: open\n states:\n open: {{ event: received }}\n quoted: {{ event: quoted }}\n won: {{ event: won }}\n lost: {{ event: lost }}\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n"
))
.expect("schema");
let schema = &schemas[&bucket];
let now = chrono::Utc::now().timestamp_millis();
// The submission: a KV record in the initial state, plus the first event.
store_answer(&js, &schemas, &bucket, "rec-1".into(), "/", "Hire us", &json!({"email": "a@b.example"}), now)
.await
.expect("store");
let store = js.get_key_value(&bucket).await.expect("bucket exists");
let stored: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
assert_eq!(stored.state, "open");
assert_eq!(stored.responses["email"], "a@b.example");
// A desk move the machine allows.
let moved = aggregates::transition(&js, schema, "rec-1", "quoted", json!({"by": "test"}), now + 1)
.await
.expect("open -> quoted is declared");
assert_eq!(moved.state, "quoted");
// Moves the machine does not allow are refused, whoever asks.
let back = aggregates::transition(&js, schema, "rec-1", "open", json!({}), now + 2).await;
assert!(matches!(back, Err(TransitionError::DisallowedTransition { .. })), "{back:?}");
let skip = aggregates::transition(&js, schema, "rec-1", "won", json!({}), now + 2).await;
assert!(skip.is_ok(), "quoted -> won is declared: {skip:?}");
let nobody = aggregates::transition(&js, schema, "rec-9", "quoted", json!({}), now + 3).await;
assert!(matches!(nobody, Err(TransitionError::UnknownAggregate)), "{nobody:?}");
// The record reads back from its own event log, in the state the
// accepted moves left it, with a sequence that moved forward.
let events = load_events(&js, &bucket, "rec-1").await.expect("events");
assert_eq!(events.len(), 3, "received, quoted, won");
let replayed = aggregates::replay(schema, "rec-1", &events).expect("replays");
assert_eq!(replayed.state, "won");
assert!(replayed.sequence > moved.sequence);
// A reply to the mail this record was sent becomes a note on it,
// through the same store the desks read - and moves nothing: the
// record is still where the desk left it.
let mut record: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
let state_before_the_note = record.state.clone();
let reply = portal::mail::Incoming {
v: portal::mail::CONTRACT,
id: "mail-1".into(),
bucket: bucket.clone(),
record: "rec-1".into(),
from: "A <A@B.example>".into(),
subject: "Re: your enquiry".into(),
text: "Thursday suits us.".into(),
received_ms: now + 10,
};
let note = portal::mail::note_for(&reply, &record).expect("the address matches the record");
record.notes.push(note);
store.put("rec-1", serde_json::to_vec(&record).unwrap().into()).await.expect("note stored");
let read_back: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
assert_eq!(read_back.notes.len(), 1);
assert_eq!(read_back.notes[0].text, "Thursday suits us.");
assert_eq!(read_back.notes[0].from, "a@b.example");
assert_eq!(read_back.state, state_before_the_note, "a reply is a note, never a decision");
// The same reply read twice adds nothing.
assert!(portal::mail::note_for(&reply, &read_back).is_err());
// And the event log is untouched by it.
let after = load_events(&js, &bucket, "rec-1").await.expect("events");
assert_eq!(after.len(), 3, "a note writes no event");
}
/// A session outlives the process that made it. The `MemoryStore` this
/// replaced could not: every deploy signed out every desk, and the
/// only proof that this one does not is a store opened twice against
/// the same JetStream, as two runs of the binary would.
#[tokio::test]
async fn a_session_survives_the_process_that_made_it() {
use tower_sessions::session::{Id, Record};
use tower_sessions::SessionStore;
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
return;
};
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
let store = portal::sessions::NatsStore::open(&js).await.expect("open");
let mut record = Record {
id: Id::default(),
data: Default::default(),
expiry_date: tower_sessions::cookie::time::OffsetDateTime::now_utc()
+ tower_sessions::cookie::time::Duration::hours(1),
};
record.data.insert("who".into(), json!("ingrid"));
store.create(&mut record).await.expect("create");
// A second store over the same JetStream is what the next boot of
// the binary has: nothing carried over in memory.
let next_boot = portal::sessions::NatsStore::open(&js).await.expect("reopen");
let found = next_boot.load(&record.id).await.expect("load").expect("still there");
assert_eq!(found.data.get("who"), Some(&json!("ingrid")));
// `create` never writes over somebody else's session: asked to
// create one whose id is taken, it takes a new id instead.
let mut collide = Record { id: record.id, data: Default::default(), expiry_date: record.expiry_date };
next_boot.create(&mut collide).await.expect("create beside it");
assert_ne!(collide.id, record.id, "an id already in use must not be reused");
assert_eq!(
next_boot.load(&record.id).await.expect("load").expect("original intact").data.get("who"),
Some(&json!("ingrid")),
"the original session must be untouched"
);
// An expired record is gone to anyone asking, whatever the bucket
// still holds.
let stale = Record {
id: Id::default(),
data: Default::default(),
expiry_date: tower_sessions::cookie::time::OffsetDateTime::now_utc()
- tower_sessions::cookie::time::Duration::minutes(1),
};
next_boot.save(&stale).await.expect("save stale");
assert!(next_boot.load(&stale.id).await.expect("load stale").is_none());
// Signing out leaves nothing to read back.
next_boot.delete(&record.id).await.expect("delete");
assert!(next_boot.load(&record.id).await.expect("load after delete").is_none());
next_boot.delete(&collide.id).await.ok();
}
+205
View File
@@ -0,0 +1,205 @@
// A `type: voice` requirement: leave a recording at a gesture key.
// Same contract as gesture.js - portal mounts it with the field's
// hidden input and the relay URL, and calls stop() on navigation.
//
// The browser records with MediaRecorder (whatever container it
// likes), decodes that to PCM with WebAudio, resamples to mono 48 kHz,
// and ships one binary frame `[0x01][20-byte key][i16le PCM]` to the
// relay, which encodes with the one recording codec, signs as itself,
// keeps it and announces it (ADR-0015).
//
// Nothing leaves the browser until the alternative's own submit
// ("Keep it here") is pressed: the widget intercepts that click,
// uploads, sets the field's value to {key, digest, duration_ms} on
// the relay's confirmation, and only then lets the submit through.
// A relay error keeps the recording for another try and submits
// nothing.
//
// Palette: colors come from the page's custom properties, no copy here.
const MAX_SECONDS = 60;
const SAMPLE_RATE = 48000;
class VoiceWidget {
constructor(container, hidden, relayUrl, key) {
this.container = container;
this.hidden = hidden || null;
this.relayUrl = relayUrl || '';
this.key = (key || '').trim();
this.stopped = false;
this.recorder = null;
this.stream = null;
this.chunks = [];
this.ws = null;
this.button = document.createElement('button');
this.button.type = 'button';
this.button.className = 'voice-button';
this.button.textContent = 'Record';
this.status = document.createElement('span');
this.status.className = 'voice-status';
this.preview = document.createElement('audio');
this.preview.className = 'voice-preview';
this.preview.controls = true;
this.preview.hidden = true;
container.append(this.button, this.status, this.preview);
this._onClick = () => this.toggle();
this.button.addEventListener('click', this._onClick);
this.pcm = null; // decoded, ready to upload
this.published = null;
this.passthrough = false;
// The alternative's submit button: hold it until the recording
// is kept at the relay.
this.card = container.closest('.alt-card');
this._onSubmit = (e) => this.onSubmitClick(e);
if (this.card) this.card.addEventListener('click', this._onSubmit, true);
if (!this.key || this.key.length !== 40) {
this.button.disabled = true;
this.say('This page has no address to leave a voice at.');
} else if (!navigator.mediaDevices || !window.MediaRecorder) {
this.button.disabled = true;
this.say('Recording needs a browser with a microphone API.');
}
}
say(text) {
this.status.textContent = text;
}
async toggle() {
if (this.recorder && this.recorder.state === 'recording') {
this.recorder.stop();
return;
}
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
this.say('Microphone access was declined.');
return;
}
this.chunks = [];
this.recorder = new MediaRecorder(this.stream);
this.recorder.addEventListener('dataavailable', (e) => { if (e.data.size) this.chunks.push(e.data); });
this.recorder.addEventListener('stop', () => this.finish());
this.recorder.start();
this.button.textContent = 'Stop';
this.button.classList.add('recording');
this.say(`Recording — up to ${MAX_SECONDS} seconds.`);
this.capTimer = setTimeout(() => { if (this.recorder && this.recorder.state === 'recording') this.recorder.stop(); }, MAX_SECONDS * 1000);
}
async finish() {
clearTimeout(this.capTimer);
this.button.textContent = 'Record again';
this.button.classList.remove('recording');
if (this.stream) { this.stream.getTracks().forEach((t) => t.stop()); this.stream = null; }
const blob = new Blob(this.chunks, { type: this.recorder.mimeType || 'audio/webm' });
if (!blob.size) { this.say('Nothing was recorded.'); return; }
this.preview.src = URL.createObjectURL(blob);
this.preview.hidden = false;
this.published = null;
this.setValue(null);
try {
this.pcm = await this.toPcm16(await blob.arrayBuffer());
} catch (e) {
this.pcm = null;
this.say('Could not decode the recording — try once more.');
return;
}
this.say(`${Math.round(this.pcm.length / 2 / SAMPLE_RATE)} s recorded. Listen back, then press the button below to keep it here.`);
}
onSubmitClick(e) {
const button = e.target.closest && e.target.closest('.alt-submit');
if (!button || this.passthrough) return;
if (this.published) return; // already kept: let the submit go
e.preventDefault();
e.stopPropagation();
if (!this.pcm) {
this.say(this.recorder && this.recorder.state === 'recording' ? 'Stop the recording first.' : 'Record something first.');
return;
}
this.say('Sending to the relay…');
this.upload(this.pcm, () => {
// The relay confirmed: now the answer itself is submitted.
this.passthrough = true;
button.click();
this.passthrough = false;
});
}
// Decode → mono 48 kHz Float32 → Int16 little-endian bytes.
async toPcm16(buffer) {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const decoded = await ctx.decodeAudioData(buffer);
await ctx.close();
const frames = Math.ceil(decoded.duration * SAMPLE_RATE);
const offline = new OfflineAudioContext(1, frames, SAMPLE_RATE);
const src = offline.createBufferSource();
src.buffer = decoded;
src.connect(offline.destination);
src.start();
const rendered = await offline.startRendering();
const f32 = rendered.getChannelData(0);
const out = new Uint8Array(f32.length * 2);
const view = new DataView(out.buffer);
for (let i = 0; i < f32.length; i++) {
const s = Math.max(-1, Math.min(1, f32[i]));
view.setInt16(i * 2, s < 0 ? s * 32768 : s * 32767, true);
}
return out;
}
upload(pcm, onPublished) {
if (!this.relayUrl) { this.say('No relay to send to.'); return; }
const frame = new Uint8Array(1 + 20 + pcm.length);
frame[0] = 1;
for (let i = 0; i < 20; i++) frame[1 + i] = parseInt(this.key.substr(i * 2, 2), 16);
frame.set(pcm, 21);
const ws = new WebSocket(this.relayUrl);
ws.binaryType = 'arraybuffer';
this.ws = ws;
ws.addEventListener('open', () => ws.send(frame));
ws.addEventListener('message', (e) => {
let msg; try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type === 'published') {
this.published = { key: this.key, digest: msg.digest, duration_ms: msg.duration_ms };
this.setValue(this.published);
this.say(`Kept at this address — ${Math.round(msg.duration_ms / 1000)} s.`);
this.button.disabled = true;
ws.close();
if (onPublished) onPublished();
} else if (msg.type === 'error') {
this.say((msg.message || 'The relay declined the recording.') + ' Your recording is still here — try again.');
ws.close();
}
});
ws.addEventListener('error', () => this.say('The relay could not be reached. Your recording is still here — try again.'));
ws.addEventListener('close', (e) => {
if (!this.published && e.code !== 1000 && e.code !== 1005) {
this.say('The relay closed the connection. Your recording is still here — try again.');
}
});
}
setValue(value) {
if (!this.hidden) return;
this.hidden.value = value ? JSON.stringify(value) : '';
this.hidden.dispatchEvent(new Event('input', { bubbles: true }));
}
stop() {
this.stopped = true;
clearTimeout(this.capTimer);
if (this.recorder && this.recorder.state === 'recording') this.recorder.stop();
if (this.stream) this.stream.getTracks().forEach((t) => t.stop());
if (this.ws) this.ws.close();
this.button.removeEventListener('click', this._onClick);
if (this.card) this.card.removeEventListener('click', this._onSubmit, true);
}
}
export function mountVoice(container, hidden, relayUrl, key) {
return new VoiceWidget(container, hidden, relayUrl, key);
}