Compare commits

...
22 Commits
Author SHA1 Message Date
Bendik LynghaugandClaude Opus 5 5b381e4f60 Type a payload's PayloadVersion as an integer
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 2m11s
Publish release / publish (push) Successful in 1m54s
The key is typed in the plist, and each payload carried a string where
the top level carried an integer - which iOS may refuse outright. A test
now holds all three to the same type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 11:25:08 +02:00
Bendik LynghaugandClaude Opus 5 44c7ee6870 Render an Apple configuration profile from the server's PACC document
Publish release / publish (push) Successful in 1m52s
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 2m9s
iOS is the one client none of the mail autoconfiguration reaches: Apple
Mail reads neither Mozilla autoconfig nor Autodiscover for IMAP, so
adding an account means the device probing for ports - and a probe that
meets a filtered port waits out a TCP timeout and reports the account as
unverifiable, which the person reads as a wrong password.

A profile states the hosts and the ports instead, and carries mail,
calendars and contacts in one install. It invents none of them: site.yaml
names a PACC document - the standard defined to be fetched from the mail
domain itself - and every host, port and collection path comes from
there, so the server is described in one place and the profile follows it.

The document holds no address, username or password. iOS asks for those
at install, which means one profile serves everyone with an account and
it can be fetched by anyone: it says no more than PACC already says in
public. Identifiers are derived from the site and the payload rather than
drawn at random, because iOS replaces a profile whose ids it knows and
stacks a duplicate when they differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 11:18:46 +02:00
Bendik LynghaugandClaude Opus 5 43a2f3a069 Document files at the site root
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 2m4s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 10:31:21 +02:00
Bendik LynghaugandClaude Opus 5 c9faaeb4a6 Serve a content repo's .well-known/ at the site root
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m3s
Publish release / publish (push) Successful in 1m51s
Everything a site has to publish for another program to find it lives
under /.well-known/, and portal had no way to put a file there: the
catch-all answered those paths 200 with the app shell, so a client
probing for security.txt or matrix/server got HTML where it expected a
document, and could not tell a missing file from a present one.

The prefix is the one place this is safe to overlay. RFC 8615 reserves
it, and a question's id comes from a file name under questions/, which
cannot begin with a dot - so no file a repo ships can shadow a page. The
path still goes through is_safe_site_path, so it cannot climb out of the
repo either, and a miss is a real 404.

Types: an extensionless file under the prefix is JSON, which is what
matrix/server and nodeinfo are; .mobileconfig is the Apple profile type,
without which iOS downloads a profile instead of offering to install it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 10:30:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 1a2c064d3a Release 0.6.20
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m11s
Publish release / publish (push) Successful in 1m55s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 12:56:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 fe7a868b18 A platform's file may name its architecture
A Mac is offered the macos-arm64 file rather than sent past it to
TestFlight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 12:56:19 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 aee5048b01 Release 0.6.19
docs / deploy (push) Successful in 3s
Publish release / publish (push) Successful in 2m50s
Test / test (push) Successful in 2m10s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 12:48:42 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 268d545843 A releases manifest renders as a download card, the device's file first
A kind: url resource whose JSON is a releases manifest - latest,
versions newest first with per-platform assets, channels such as a
package repo or TestFlight - is one card: the newest version, the file
for the visitor's own platform (read from the browser on hydration;
the server renders every file), the Linux package repo lines beside a
Linux file, TestFlight for iPhones and Macs, and the rest folded under
"All downloads". Decided by shape, as every resource is; written for
redoal's public releases at dl.redoal.com.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 12:48:31 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 22d086f9e1 A resource can show only its newest few
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m0s
`limit: n` on a resource spec, applied after `jq`.

The releases card is what asked for it: a page that grows by one card
per release gets longer forever, and the cut a visitor should run is the
newest one — the rest are history, not an invitation. redoal's card had
every release on it.

After `jq` rather than before, for two reasons: it caps what the page
will actually render whatever the filter did, and content that reorders
with the filter can then take the top of its own ordering rather than
the top of the source's. A source yielding a single record has no length
and passes through untouched — turning one record into an empty list
would be worse than ignoring a limit that cannot apply. `limit: 0` on a
list does mean nothing, and the `empty` line speaks instead, which is a
coherent thing for content to ask for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByshXuDEzH8hXh5qp4VgGk
2026-10-04 22:51:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 48ae5e07c2 Release 0.6.18
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m0s
Publish release / publish (push) Successful in 1m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:48:22 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 b619926a11 generate follows its findings through, and stops when it stalls
Each structure round is logged as fixed, still open, new and reopened;
a fix round sees the open findings with their fixes and only the files
they name; a change that reopens a closed finding is undone; a round
that closes nothing ends the phase with what is left in run.log. A
hesitant visitor alone no longer triggers a wording rewrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:48:09 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 706c8108df iris check --findings: every finding as JSON with a stable id
The id hashes scope, need, code and subject, nothing of the wording, so
the same gap keeps its id across runs. Every message that knows its fix
says it after "fix:". Codes listed in docs/lint.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:48:09 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 2feeea0810 Release 0.6.17
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m12s
Publish release / publish (push) Successful in 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
2026-10-02 11:26:13 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 6b91258ebe One set of mail variables for every mail, and {link} in state mails
{site} and {site_name} everywhere; a state mail adds {self_link} (the
submitter's own page, when their record's state allows a move there),
{desk_link} and {link}, which picks by who the mail is to. gullberg's
{link} mails went out blank until now. Documented in docs/mail.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
2026-10-02 11:25:49 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 ae119af987 A desk shows the three newest finished records, and older on request
Every record still moving stays; of those in a final state only the
three most recently decided, plus one so the page knows to offer
"Show older", three more at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
2026-10-02 11:25:49 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 92dd9bfc79 The lint walks every state of every bucket a need lands in
Clocks count as moves and a visitor's link only from its own states.
Per bucket: a state nothing reaches fails, a state a record can reach
and never leave fails (after done too), a declared move nobody offers
warns, a desk gated twice warns, and a mail from an unreachable state
or naming a field no form collects is caught. Working stages must
follow each other. Every finding ends in its fix, and a schema error no
longer hides the needs findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
2026-10-02 11:25:49 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 2cc74e3354 CI builds and lints every plan with this portal
Test / test (push) Successful in 2m35s
The check portal-trainer's CI ran with the standalone trainer, which
kept failing on a builder bug fixed here long ago. Now it runs where the
trainer lives, with the same binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
2026-10-02 09:27:44 +02:00
Bendik Aagaard Lynghaug 7b54c4c6d7 Release 0.6.16
Test / test (push) Successful in 1m8s
Publish release / publish (push) Successful in 1m56s
2026-10-01 21:46:45 +02:00
bl 951fac9aec Merge pull request 'Test fixtures get the action the lint now requires' (#28) from fixture-action into main
Test / test (push) Successful in 1m10s
2026-10-01 19:46:29 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 738dbac7fe Test fixtures: every form has the action the lint now requires
Test / test (pull_request) Successful in 21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:46:24 +02:00
bl 98f93f5ad5 Merge pull request 'A form with no action is refused; portal iris' (#27) from form-needs-action into main
docs / deploy (push) Successful in 3s
Test / test (push) Failing after 1m7s
2026-10-01 19:42:03 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 4049809aa4 The lint refuses a form with no action, which would have no button; portal iris names the lint
Test / test (pull_request) Failing after 1m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:41:56 +02:00
56 changed files with 8652 additions and 1532 deletions
+19
View File
@@ -38,6 +38,25 @@ jobs:
- name: Test
run: cargo test --features ssr
# Every plan under plans/ built deterministically and held to the
# lint, both by this very portal: a builder change that renders a
# site the lint rejects fails here, not at the next training run.
# (portal-trainer's CI did this until the trainer moved in.)
- name: Build and lint every plan
run: |
set -euo pipefail
cargo build --features ssr --bin portal
bin="$CARGO_TARGET_DIR/debug/portal"
tmp=$(mktemp -d)
lint="$tmp/portal-lint"
printf '#!/bin/sh\nexec %s lint "$@"\n' "$bin" > "$lint" && chmod +x "$lint"
for plan in plans/*.yaml; do
name=$(basename "$plan" .yaml)
echo "== $plan"
rm -rf "$tmp/plan-$name"
"$bin" build --plan "$plan" --out "$tmp/plan-$name" --lint "$lint"
done
- name: Stop the throwaway JetStream
if: always()
run: docker rm -f portal-test-nats >/dev/null 2>&1 || true
+36
View File
@@ -4,6 +4,42 @@ 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.6.23 (2026-10-07)
- A configuration profile's per-payload `PayloadVersion` is an integer, as the plist key is typed; it was a string, which iOS may refuse.
## 0.6.22 (2026-10-07)
- `/profile.mobileconfig` renders an Apple configuration profile from the mail server's own PACC document (`profile.pacc` in `site.yaml`), so iOS can be set up in one install instead of probing for ports it cannot reach. Mail, CalDAV and CardDAV in one profile; every host, port and path from PACC, never written down twice; no address, username or password in the document, so one profile serves everyone and iOS asks at install; identifiers derived from the site, so reinstalling replaces the accounts rather than duplicating them (docs: content-reference, "A configuration profile").
## 0.6.21 (2026-10-07)
- A content repo's `.well-known/` is served at the site root (`/.well-known/security.txt`, `/.well-known/assetlinks.json`, `/.well-known/matrix/server`), and a miss there is a 404 instead of the app shell answering 200 with an HTML page. RFC 8615 reserves the prefix and a question's id cannot begin with a dot, so nothing a repo ships can shadow a page. An extensionless file under it is typed as JSON, by the convention those files follow; `.txt`, `.xml`, `.webmanifest` and `.mobileconfig` are typed too, the last so an Apple configuration profile offers to install rather than download.
## 0.6.20 (2026-10-05)
- A releases card lets a platform's file carry an architecture: a Mac visitor is offered the `macos-arm64` file, not sent to TestFlight past it.
## 0.6.19 (2026-10-05)
- A `kind: url` resource whose JSON is a releases manifest (`latest`, `versions` with per-platform `assets`, `channels` such as a package repo or TestFlight) renders as a download card: the file for the visitor's own platform first, decided in the browser, the rest and the channels folded. Written for redoal's public releases at dl.redoal.com (docs: content-reference, "A releases manifest").
## 0.6.18 (2026-10-03)
- `iris check --findings <out.jsonl>` writes every finding as JSON: a stable `id` (scope, need, code and subject, nothing of the wording), `level`, `code`, `scope`, `need`, `subject`, `message` and `fix`. Every message that knows its fix now says it after "fix:". The codes are listed in docs/lint.md.
- `portal generate` follows its findings through: each structure round is logged as fixed, still open, new and reopened; a fix round is shown the open findings with their fixes and only the files they name; a change that reopens a closed finding is undone; and the phase stops when a round closes nothing, writing what is left to run.log. A hesitant visitor no longer triggers a wording rewrite by itself, only a misroute does. A lint without `--findings` is still driven the old way.
## 0.6.17 (2026-10-02)
- A desk shows every record still moving and only the three most recently finished (a final state: no move out of it), with "Show older" for three more at a time. A desk that has run for a year is still about the work in hand.
- Mail variables are one set for every kind of mail (docs: Mail). Every mail has `{site}` and `{site_name}`; a state mail also has `{link}`, `{self_link}` and `{desk_link}`: the reader's own page for the record (a self-service page with `?chain=&email=`, when the record's state allows a move there), the desk that moves it, and `{link}` choosing between them by who the mail is to. `{link}` in a state mail used to render as nothing.
- The lint follows every state of every bucket a need lands in: a state no desk, link or clock reaches fails; a state a record can reach and never leave fails, after "done" too; a declared move nobody offers warns; a desk gated to one group on the page and another on its list warns; working stages must follow each other in the business's order; a mail from an unreachable state warns, and one naming a field no form collects fails. Clocks (`after:`) count as moves, and a visitor's own link only from the states it names. Each finding ends in the fix: the `{ from, to, label }` and the desk page it goes on, or the group that already has the button. A schema error no longer hides the needs findings.
## 0.6.16 (2026-10-01)
- The lint refuses an alternative that has a form to send (fields to fill in, an invite, or a bucket to record into) and no `action`. The button is only drawn for an alternative with an `action`, so such a form could be filled in and never sent, which is what happened to uhhm.no's first invite form. An alternative that moves its own record (`self_transition`) draws its own button and is left alone.
- `portal iris` is the lint's name, after the iris that keeps bad content out of the portal. `portal lint` is the same command and keeps working.
## 0.6.15 (2026-10-01)
- An invite may carry its own mail: `invite: { group, mail: { subject, body } }`. Every field of the form is a placeholder besides `{name}`, `{link}`, `{expires}` and the rest, and a date and time (`type: datetime-local`) is written out in words ("tirsdag 6. oktober kl. 15:00"). The words and the form are kept with the invite, so sending it again sends the same mail. This is for a desk that invites someone to a conversation at an hour, not into a group for good.
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.6.15"
version = "0.6.23"
dependencies = [
"anyhow",
"arc-swap",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.6.15"
version = "0.6.23"
edition = "2021"
[lib]
@@ -60,6 +60,7 @@ web-sys = { version = "0.3", features = [
"FileList",
"FormData",
"HtmlInputElement",
"Navigator",
"Request",
"RequestInit",
"Response",
+1
View File
@@ -7,6 +7,7 @@
- [Getting started](getting-started.md)
- [Content reference](content-reference.md)
- [State machines](state-machines.md)
- [Mail](mail.md)
- [Access](access.md)
- [Storylines](storylines.md)
- [Memberships](memberships.md)
+102
View File
@@ -97,6 +97,79 @@ Pictures in the content repo are served from this origin under
`/site/`; `webp`, `avif` and `jpeg` may be kept by the browser for an
hour. Keep them web-sized: around 1400 pixels tall is plenty.
## Files at the site root
A repo's `.well-known/` directory is served at the root of the site, at
the same paths it has in the repo:
```
.well-known/security.txt -> https://<site>/.well-known/security.txt
.well-known/assetlinks.json -> https://<site>/.well-known/assetlinks.json
.well-known/matrix/server -> https://<site>/.well-known/matrix/server
```
This is the one part of the URL space a repo may write into directly.
RFC 8615 reserves `/.well-known/`, and a question's id comes from a file
name under `questions/`, which cannot begin with a dot - so nothing
shipped here can shadow a page. A path that is not in the repo answers
**404**, not the app shell, so a program looking for one of these can
tell a missing file from a present one.
A file with no extension is served as JSON, which is what the
extensionless well-known files are. `.txt`, `.xml`, `.webmanifest` and
`.mobileconfig` are typed as themselves; the last matters because iOS
only offers to *install* a configuration profile that arrives as
`application/x-apple-aspen-config`.
Paths a reverse proxy claims in front of portal never reach the repo -
mail autoconfiguration (`/.well-known/autoconfig/`) and the CalDAV and
CardDAV redirects are usually handed to a mail server that generates
them.
## A configuration profile
iOS has no autoconfiguration for IMAP accounts - Apple Mail reads neither
Mozilla autoconfig nor Microsoft Autodiscover, which are for ActiveSync - so
adding an account by hand leaves the device probing for ports. A probe that
meets a *filtered* port, a firewall that drops rather than refuses, waits out
a TCP timeout, and the account comes back "could not be verified", which reads
to the person as a wrong password. An Apple configuration profile ends the
guessing by stating the hosts and the ports, and it carries mail, calendars
and contacts in a single install.
Portal renders one at `/profile.mobileconfig` when `site.yaml` says where to
get the values:
```yaml
profile:
pacc: https://mail.example.no/.well-known/user-agent-configuration.json
name: Example # what the accounts are called on the device
organization: Example AS # the profile's publisher, defaults to name
```
`pacc` is the mail server's **PACC** document - the standard that replaces
guessing at hostnames, defined to be fetched from the mail domain itself, and
served by Stalwart among others. Every host, port and collection path in the
profile comes from it, so nothing about the server is written down a second
place to drift from the first: move a collection on the server and the profile
moves with it. Where PACC states no port, the registered implicit-TLS default
is used (993 for IMAP, 465 for submission, 443 for DAV).
What the profile contains is only what PACC already says in public. It carries
**no address and no username** - iOS asks for those and the password at
install - so one document serves everyone with an account and may be fetched
by anyone. Payload identifiers are derived from the site and the payload
rather than drawn at random, so reinstalling replaces the accounts instead of
stacking duplicates beside them.
Two things it cannot do. A profile Portal renders is unsigned, so iOS shows it
as *Unverified* while still offering to install it; signing needs a
certificate the device already trusts. And a PACC document describing no
protocol iOS understands - JMAP alone, or an incoming server with no outgoing
one - is an error rather than a half-empty profile that looks like it worked.
No `profile:` means `/profile.mobileconfig` is a 404.
## An alternative
One choice. Never two actions behind one heading: if two things can be
@@ -255,6 +328,7 @@ reads is never a client-supplied parameter.
states: [approved, refused] # only these; empty means all
where: { field: case, equals: "{key}" }
jq: ".[] | {name, url: .html_url}"
limit: 1 # at most this many, after jq
empty: Nothing decided yet.
link: { to: "/notices/{id}", label: Answer this } # a page per record
transitions: # the desk's buttons
@@ -264,6 +338,14 @@ reads is never a client-supplied parameter.
`source.kind` is one of `kv`, `gitea_starred`,
`gitea_releases`, `url`.
`limit: 1` shows only the first item — for a feed where only the newest
matters. A releases card wants the cut someone should run, not every
cut there has ever been, and a page that grows by one card per release
gets longer forever. It applies *after* `jq`, so a filter can reorder
and the limit takes the top of that ordering. A source that yields a
single record has no length and ignores it; `limit: 0` on a list shows
nothing, and the `empty` line speaks instead.
A spec with neither `public: true` nor `requires_group` is
unreachable: reads fail closed, and mutations always need a group
whatever `public` says.
@@ -285,6 +367,26 @@ markdown on stricter rules than the site's own: paragraphs, line
breaks as typed, lists, emphasis and links to https, mailto or tel
(marked `nofollow ugc`); no images, no raw HTML.
A desk (a list with `transitions`) shows every record still moving,
and of those in a final state - one the state machine has no move out
of - only the three most recently decided, newest first. "Show older"
under the list brings three more each time it is pressed. The desk
stays about the work in hand however long the site has run; nothing
is hidden from a read-only list, and `leaves_after` still takes a
record out of every list altogether.
A **releases manifest** - a `kind: url` resource whose JSON is
`{ name, latest, versions: [{ version, date, notes, assets: [{ platform,
url, sha256, size }] }], channels: { arch: { repo, package, note },
ios: { testflight, note } } }`, newest version first, as a release
pipeline writes it - renders as one card: the newest version, the file
for the visitor's own platform first (read from the browser: `ios`,
`macos`, `linux-x86_64`, `linux-aarch64`, `windows`, `android`), the
other files and the channels folded under "All downloads". Linux gets
the package repo lines beside its file; iPhones and Macs get
TestFlight. Platforms a manifest lacks fall through to the folded
list.
`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
+42 -2
View File
@@ -4,11 +4,15 @@ The iris over a site. A content push is an incoming wormhole; nothing
comes through until it checks out.
```
portal lint --path questions # a local checkout
portal iris --path questions # a local checkout
portal lint --repo https://project.uhhm.no/uhhm/questions
portal lint replay --path questions --nats nats://127.0.0.1:14222
```
`portal iris` is the lint's name, after the iris that keeps bad content
out of the portal; `portal lint` runs the same thing, so workflows that
already call it keep working.
It was its own binary, iris, until portal 0.6: the same code, released
as portal's twin. Now it is the `portal` binary's `lint` command, so a
content repo's CI downloads the release and runs it:
@@ -30,7 +34,43 @@ in `needs.yaml`, when it has one:
- the handling group holds a desk over that bucket, and every
finished state is reachable through the buttons it (and any
`also_moved_by` group) offers; no record strands on the way;
- every stage the business named is a state of the machine.
- every stage the business named is a state of the machine, and each
working stage can follow the one before it;
- across each bucket, whichever need brought a record in: every state
is somewhere a record can get to, no state a record gets to holds it
with no way on (before done or after), every declared move is a
button, a visitor's own link or a clock (a move nobody offers warns),
no desk is gated to one group on the page and another on its list,
and every mail goes out from a state records reach and names only
fields the forms collect.
Every finding says what to change and where: the missing
`{ from, to, label }` and the desk page it goes on, the group that
already has the button, or the edge aggregates.yaml lacks. A schema
error no longer hides the needs findings - both are printed, so one
round can fix them together.
`--findings <out.jsonl>` writes the same findings one JSON object per
line: `id`, `level` (`fail`/`warn`), `code`, `scope` (`need`,
`bucket`, or `repo` for a schema error), `need` (the need's id, or the
bucket's name), `subject` (the state, the move as `from>to`, or the
page), `message` and `fix`. The file is written empty when the content
is clean. `id` hashes scope, need, code and subject and nothing of the
wording, so the same gap keeps its id from one run to the next: a
trainer can tell what a round closed, what is still open and what it
broke. The codes:
| code | |
|---|---|
| `schema` | content the site would refuse to load |
| `need-duplicate`, `persona-unknown-need` | needs.yaml itself |
| `entry-missing`, `entry-gated`, `entry-no-responsible` | the page a visitor arrives on |
| `path-missing`, `path-closed`, `path-over-budget`, `path-no-action` | getting the answer recorded |
| `machine-missing`, `desk-missing`, `desk-gated-twice` | who works the bucket |
| `done-unknown`, `done-unreachable`, `success-not-done` | finishing a case |
| `stage-unknown`, `stage-unreachable`, `stage-order` | the business's own stages |
| `state-unreached`, `strand`, `edge-unoffered` | every state and move of the bucket |
| `mail-unreachable`, `mail-unknown-field` | what a state mails |
A need marked `planned` warns instead of failing. Exit status is the
verdict, so a content repo's CI is one line.
+59
View File
@@ -0,0 +1,59 @@
# Mail
Portal writes every mail itself and hands it to gdo to deliver
(`portal.mail.send`); a reply comes back as a note beside the record
(`portal.mail.received`). A mail is a subject and a markdown body with
`{placeholders}`, sent as text with an HTML alternative. The kinds of
mail are the ones content declares on a state (see
[State machines](state-machines.md#mail)), the invite and its
rewording (`invite: { group, mail }`, `mail.invite` in site.yaml), the
note to a person named responsible for a question, and the answers to
a suggested change.
## Variables
A placeholder is `{` letters, digits, `_`, `-` or `.` `}`. One that
names nothing renders as nothing; a brace that opens no name stays a
brace. The variables come in layers, so a template written for one
kind of mail reads the same in another.
**On every mail**
| | |
|---|---|
| `{site}` | the instance's public URL, no trailing slash |
| `{site_name}` | site.yaml's `title` |
**On a mail about a record** (every state mail)
| | |
|---|---|
| `{field}` | any field of the record, as the form sent it |
| `{chain}` | the record's id |
| `{email}` | the record's `email` field, trimmed |
| `{bucket}`, `{state}` | where it is and the state it just entered |
| `{notice.field}` | a field of the record this one belongs to, through its `type: record` field `notice` |
| `{self_link}` | the record's own page for the person it belongs to: a page offering a self-service move (`self_transition`) on the bucket from the state the record is in, with the `?chain=&email=` that lets them act without signing in. Empty when there is no such page or no address |
| `{desk_link}` | the desk that moves the record: the page whose list over the bucket has `transitions`, else one that reads it. Empty when no page reads the bucket |
| `{link}` | where this reader acts: `{self_link}` in a mail `to: submitter`, `{desk_link}` in one to anyone else, and `{site}` when that link is empty |
`{link}` is the one to reach for. "Se meldingen og avtal befaring:
{link}" sent to the caretaker opens the desk the report is on; "Feilen
du meldte om er utbedret. {link}" sent to the tenant opens their own
case where they can still act on it, or the site when there is
nothing left for them to do.
**On an invite and the note to someone responsible**, beside every
field of the invite's form: `{name}`, `{username}`, `{email}`,
`{group}` (invite), `{questions}` (responsible), `{expires}`, and
`{link}`, which here is the one-time sign-in link.
## What the lint holds a mail to
A state mail that sends from a state no record reaches warns; one that
names a field no form recording into the bucket collects fails, since
it would go out with a blank where the field should be. The lint
knows every variable above, so `{link}` and the rest are never
flagged. `to: submitter` requires a required `email` on every form
filling the bucket, and `to: "{notice.email}"` requires the `notice`
field.
+3 -1
View File
@@ -57,7 +57,9 @@ a record arrives in it.
```
`{field}` is any field of the record, plus `{site}`, `{chain}`,
`{email}`, `{bucket}` and `{state}`. The body is markdown; it is sent
`{email}`, `{bucket}`, `{state}` and `{link}` - the page where the
reader acts on the record, their own or the desk's (every variable is
in [Mail](mail.md#variables)). 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`.
+16 -5
View File
@@ -243,13 +243,24 @@ against the lint. It is kept for the record of what it measured.
visitor will tolerate.
2. **Structure.** Pages, state machines and desks are generated, then
revised against the lint, `iris check`, until every need is met:
a path within budget, a desk held by the right group, every
finished state reachable, no record stranding. The page-writing
model sees the needs and never the personas.
a path within budget, a desk held by the right group, every state
of every bucket reachable and none stranding a record. The
page-writing model sees the needs and never the personas. Each
round the lint writes its findings with stable ids (`--findings`,
kept in `<out>/findings/round-N.jsonl`), and the round is logged as
what it fixed, what is still open, what is new and what it
reopened. A fix round is shown only the open findings, each with
the change that closes it, and the files they name (the whole repo
when a finding is about getting around the site). A change that
reopens a closed finding is undone and the model told what it
broke. A round that closes nothing, or leaves as many open as it
found, ends the phase: what is left is written to `run.log` rather
than polished at again.
3. **Wording.** A separate model plays each persona, blind, first with
headings only and then with descriptions. A misroute is a wording
defect and goes back to the generator. A rewrite that breaks
structure is reverted.
defect and goes back to the generator; a visitor who chose right
but hesitated is reported and rewrites nothing by itself. A rewrite
that breaks structure is reverted.
Model output is only ever written to `aggregates.yaml`, `site.yaml`
and `questions/**.yaml` inside the output directory.
+351 -68
View File
@@ -70,12 +70,22 @@ pub enum Principal {
impl Principal {
pub fn anonymous() -> Self {
Principal::User { sub: "anonymous".into(), groups: Vec::new(), phone: None, email: None }
Principal::User {
sub: "anonymous".into(),
groups: Vec::new(),
phone: None,
email: None,
}
}
pub fn from_user(user: Option<&crate::auth::User>) -> Self {
match user {
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone(), phone: u.phone.clone(), email: u.email.clone() },
Some(u) => Principal::User {
sub: u.sub.clone(),
groups: u.groups.clone(),
phone: u.phone.clone(),
email: u.email.clone(),
},
None => Principal::anonymous(),
}
}
@@ -110,13 +120,23 @@ impl Action {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Resource {
Page { id: String },
Bucket { name: String },
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 },
Feature {
page: String,
alternative: String,
feature: String,
},
Group {
name: String,
},
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -164,12 +184,29 @@ impl Policy {
// 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 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}") };
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 });
rules.push(Rule {
id: id.clone(),
who,
action: action.to_string(),
resource,
when,
});
sources.push((id, policy));
};
@@ -179,7 +216,10 @@ impl Policy {
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))),
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(),
@@ -197,16 +237,25 @@ impl Policy {
}
for req in &feature.requirements {
if let Some(spec) = &req.resource {
specs.push((format!("{}|{}|{}|{}", q.id, alt.name, feature.name, req.name), spec));
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));
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))),
(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,
@@ -279,20 +328,37 @@ impl Policy {
.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 }
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);
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("; "))
Err(result
.validation_errors()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; "))
}
}
@@ -317,7 +383,10 @@ impl Policy {
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))
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 {
@@ -332,8 +401,14 @@ impl Policy {
}
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"));
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 } => {
@@ -345,9 +420,14 @@ impl Policy {
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::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()));
@@ -357,22 +437,32 @@ impl Policy {
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 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 {
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(),
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
self.decide(principal, action, resource, serde_json::json!({}))
.allow
}
}
@@ -473,21 +563,50 @@ pub async fn serve(
// A question that names a person and lists no groups is
// asking portal what they belong to: their memberships,
// matched on subject, phone number or address.
if let (Principal::User { sub, groups, phone, email }, Some(state)) = (&mut q.principal, &state) {
if let (
Principal::User {
sub,
groups,
phone,
email,
},
Some(state),
) = (&mut q.principal, &state)
{
if groups.is_empty() && sub != "anonymous" {
let who = crate::memberships::Identity {
sub: Some(sub.clone()),
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
phone: phone.as_deref().and_then(|p| {
crate::memberships::normalize_phone(
p,
&std::env::var("PHONE_COUNTRY")
.unwrap_or_else(|_| "+47".into()),
)
}),
email: email.as_deref().map(|e| e.trim().to_lowercase()),
};
*groups = crate::memberships::groups_of(&crate::memberships::all(state).await, &who);
*groups = crate::memberships::groups_of(
&crate::memberships::all(state).await,
&who,
);
}
}
decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context)
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}")] },
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 bytes = serde_json::to_vec(&decision)
.unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec());
let _ = nats.publish(reply, bytes.into()).await;
}
}
@@ -513,14 +632,22 @@ mod tests {
}
fn user(groups: &[&str]) -> Principal {
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: None }
Principal::User {
sub: "u".into(),
groups: groups.iter().map(|g| g.to_string()).collect(),
phone: None,
email: None,
}
}
#[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"));
assert!(p
.rules
.iter()
.any(|r| r.id == "move:applicants:open>invited"));
}
#[test]
@@ -530,7 +657,12 @@ mod tests {
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!({}));
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.
@@ -541,8 +673,16 @@ mod tests {
#[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() };
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));
@@ -551,26 +691,88 @@ mod tests {
#[test]
fn transitions_are_exactly_the_desks_buttons() {
let p = site();
let bucket = Resource::Bucket { name: "applicants".into() };
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);
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);
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);
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);
assert!(
!p.decide(
&user(&["owners"]),
Action::SelfTransition,
&bucket,
ctx("a@b.c")
)
.allow
);
}
// The applicant written to for a missing drawing sends it from
@@ -590,15 +792,52 @@ mod tests {
.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 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);
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);
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
@@ -606,8 +845,17 @@ mod tests {
#[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);
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
@@ -633,20 +881,49 @@ mod tests {
"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);
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() };
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));
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));
}
@@ -657,12 +934,18 @@ mod tests {
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);
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!(
p.decide(&q.principal, q.action, &q.resource, q.context)
.allow
);
assert_eq!(subject_for("uhhm"), "portal.access.uhhm.may");
}
+20 -7
View File
@@ -78,7 +78,9 @@ fn is_zero(n: &u32) -> bool {
impl LeavesSpec {
pub fn millis(&self) -> i64 {
((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes)) * 60 * 1000
((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes))
* 60
* 1000
}
}
@@ -200,8 +202,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
let mut event_for_state = HashMap::new();
let mut state_for_event = HashMap::new();
for (state, raw_state) in &raw_schema.states {
if let Some(existing) = state_for_event.insert(raw_state.event.clone(), state.clone())
{
if let Some(existing) = state_for_event.insert(raw_state.event.clone(), state.clone()) {
anyhow::bail!(
"aggregates.yaml: bucket {bucket:?}: event {:?} used by both state {:?} and {:?}",
raw_state.event, existing, state
@@ -238,7 +239,9 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
}
if let Some(group) = raw_state.grants {
if group.trim().is_empty() {
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: grants needs a group");
anyhow::bail!(
"aggregates.yaml: bucket {bucket:?}: state {state:?}: grants needs a group"
);
}
grants_for_state.insert(state.clone(), group);
}
@@ -248,7 +251,11 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
}
// 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)) {
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
@@ -261,7 +268,10 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: mail needs a subject and a body");
}
let through_a_record = crate::mail::parent_of(&mail.to).is_some();
if !mail.to_submitter() && !through_a_record && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
if !mail.to_submitter()
&& !through_a_record
&& !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace))
{
anyhow::bail!(
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter`, one address, or a field of the record this one belongs to like `{{notice.email}}`, not {:?}",
mail.to
@@ -334,7 +344,10 @@ pub fn replay(schema: &AggregateSchema, id: &str, events: &[Event]) -> Option<Ag
pub enum TransitionError {
/// No creation event found for this id - nothing to transition.
UnknownAggregate,
DisallowedTransition { from: String, to: String },
DisallowedTransition {
from: String,
to: String,
},
/// A concurrent writer advanced this aggregate past the sequence
/// this call read (see `events::store::append_event`'s CAS).
Conflict,
+28 -6
View File
@@ -37,7 +37,11 @@ pub async fn run(state: AppState) {
pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
let now_ms = chrono::Utc::now().timestamp_millis();
let questions = state.questions.load();
let events: Vec<_> = questions.values().filter(|q| q.event.is_some()).cloned().collect();
let events: Vec<_> = questions
.values()
.filter(|q| q.event.is_some())
.cloned()
.collect();
if events.is_empty() {
return Ok(());
}
@@ -100,7 +104,9 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
let mut refreshed = answer.clone();
refreshed.responses = responses.clone();
if let Some(store) = &store {
store.put(&id, serde_json::to_vec(&refreshed)?.into()).await?;
store
.put(&id, serde_json::to_vec(&refreshed)?.into())
.await?;
tracing::info!(question = %q.id, "event record refreshed from content");
}
}
@@ -108,15 +114,31 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
if answer.state == schema.initial && now_ms >= ends {
let target = "awaiting_summary";
let payload = serde_json::json!({ "to": target, "item": id, "by": "portal" });
match crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms).await {
match crate::aggregates::transition(
&state.jetstream,
schema,
&id,
target,
payload.clone(),
now_ms,
)
.await
{
Ok(_) => {}
Err(crate::aggregates::TransitionError::UnknownAggregate) => {
crate::aggregates::reseed(&state.jetstream, schema, &id, &answer.state, now_ms)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
crate::aggregates::transition(
&state.jetstream,
schema,
&id,
target,
payload.clone(),
now_ms,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Err(e) => return Err(anyhow::anyhow!("{e}")),
}
+134 -25
View File
@@ -38,6 +38,11 @@ pub struct Answer {
/// not see before.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<Note>,
/// Set by a desk listing, never stored: the record is in a final
/// state (one with no move out). A desk shows the newest few of
/// those and offers the rest on request (`resource::cap_finished`).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub finished: bool,
}
/// One reply on a record. It lives here rather than with the mail
@@ -97,6 +102,7 @@ pub async fn store_answer(
storyline,
decided_ms: None,
notes: Vec::new(),
finished: false,
decided_by: None,
};
let data = serde_json::to_vec(&answer)?;
@@ -107,7 +113,9 @@ pub async fn store_answer(
// submission the NATS notification event has already recorded"
// policy as the KV write above.
if let Some(schema) = aggregates.get(bucket) {
if let Err(e) = crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await {
if let Err(e) =
crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await
{
tracing::error!(bucket, id, error = %e, "failed to seed event log for submission");
}
}
@@ -121,17 +129,27 @@ pub const OWNER_FIELD: &str = "by";
/// Every record in `bucket` that belongs to `username`, newest first.
#[cfg(feature = "ssr")]
pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, username: &str) -> Vec<Answer> {
pub async fn own_records(
js: &async_nats::jetstream::Context,
bucket: &str,
username: &str,
) -> Vec<Answer> {
use futures::TryStreamExt;
let Ok(store) = js.get_key_value(bucket).await else { return Vec::new() };
let Ok(store) = js.get_key_value(bucket).await else {
return Vec::new();
};
let keys: Vec<String> = match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => return Vec::new(),
};
let mut own = Vec::new();
for key in keys {
let Ok(Some(bytes)) = store.get(&key).await else { continue };
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
let Ok(Some(bytes)) = store.get(&key).await else {
continue;
};
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else {
continue;
};
if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) {
own.push(answer);
}
@@ -145,9 +163,16 @@ pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, user
/// it (the event log keeps them). Run after every store, so two sends
/// racing each other - a double click - both settle on the same one.
#[cfg(feature = "ssr")]
pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str, initial: &str, username: &str) {
pub async fn keep_newest_own(
js: &async_nats::jetstream::Context,
bucket: &str,
initial: &str,
username: &str,
) {
let own = own_records(js, bucket, username).await;
let Ok(store) = js.get_key_value(bucket).await else { return };
let Ok(store) = js.get_key_value(bucket).await else {
return;
};
for older in own.iter().filter(|a| a.state == initial).skip(1) {
if let Err(e) = store.delete(&older.id).await {
tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record");
@@ -161,16 +186,39 @@ pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str,
/// the address a list would open it from, and may be left empty to
/// keep it.
#[cfg(feature = "ssr")]
pub async fn fill_own(js: &async_nats::jetstream::Context, question: &mut crate::content::Question, user: Option<&crate::auth::User>) {
pub async fn fill_own(
js: &async_nats::jetstream::Context,
question: &mut crate::content::Question,
user: Option<&crate::auth::User>,
) {
let Some(user) = user else { return };
for alt in &mut question.alternatives {
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { continue };
let Some(mine) = own_records(js, &bucket, &user.username).await.into_iter().next() else { continue };
for req in alt.features.iter_mut().flat_map(|f| f.requirements.iter_mut()) {
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else {
continue;
};
let Some(mine) = own_records(js, &bucket, &user.username)
.await
.into_iter()
.next()
else {
continue;
};
for req in alt
.features
.iter_mut()
.flat_map(|f| f.requirements.iter_mut())
{
if req.kind == "hidden" {
continue;
}
let Some(value) = mine.responses.get(&req.name).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty()) else { continue };
let Some(value) = mine
.responses
.get(&req.name)
.and_then(|v| v.as_str())
.filter(|v| !v.trim().is_empty())
else {
continue;
};
if req.kind == "file" {
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
req.optional = true;
@@ -211,7 +259,12 @@ pub async fn transition_answers(
.load()
.at(storyline)
.get(&question_id)
.and_then(|q| q.alternatives.iter().find(|a| a.name == alternative).cloned())
.and_then(|q| {
q.alternatives
.iter()
.find(|a| a.name == alternative)
.cloned()
})
.ok_or_else(|| ServerFnError::new("unknown question or alternative"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
@@ -327,7 +380,9 @@ async fn apply_transition(
&state.access.load(),
&crate::access::Principal::from_user(Some(user)),
crate::access::Action::Transition,
&crate::access::Resource::Bucket { name: bucket.to_string() },
&crate::access::Resource::Bucket {
name: bucket.to_string(),
},
serde_json::json!({ "from": answer.state, "to": item.to }),
);
if !decision.allow {
@@ -364,7 +419,15 @@ 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, answer.storyline).await;
crate::mail::on_state(
state,
bucket,
&item.item_id,
&item.to,
&answer.responses,
answer.storyline,
)
.await;
crate::people::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
// An expired invite sent again: the move back is the asking.
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
@@ -374,7 +437,12 @@ async fn apply_transition(
// The decision extends the DAG as a child of the answer's own
// submission hash, published like any other answer.
let parent_hashes = vec![item.item_id.clone()];
let chain_hash = hash_node(question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
let chain_hash = hash_node(
question_id,
&parent_hashes,
&decision_payload_for_event,
decided_ms,
);
let event = AnswerSubmitted {
chain_hash,
parent_hashes,
@@ -441,13 +509,22 @@ pub async fn self_transition_answer(
// 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 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::Principal::Submitter {
record: item_id.clone(),
email: email.clone(),
},
crate::access::Action::SelfTransition,
&crate::access::Resource::Bucket { name: self_transition.bucket.clone() },
&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 {
@@ -462,8 +539,15 @@ pub async fn self_transition_answer(
.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 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();
@@ -493,11 +577,31 @@ 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, answer.storyline).await;
crate::people::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
crate::mail::on_state(
&state,
&self_transition.bucket,
&item_id,
&self_transition.to,
&answer.responses,
answer.storyline,
)
.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);
let chain_hash = hash_node(
&question_id,
&parent_hashes,
&decision_payload_for_event,
decided_ms,
);
let event = AnswerSubmitted {
chain_hash,
parent_hashes,
@@ -537,6 +641,7 @@ mod tests {
decided_ms: None,
decided_by: None,
notes: Vec::new(),
finished: false,
},
Answer {
id: "def456".to_string(),
@@ -549,10 +654,14 @@ mod tests {
decided_ms: Some(1_700_000_002_000),
decided_by: Some("bl".to_string()),
notes: Vec::new(),
finished: false,
},
];
let value = serde_json::Value::Array(
answers.iter().map(|a| serde_json::to_value(a).unwrap()).collect(),
answers
.iter()
.map(|a| serde_json::to_value(a).unwrap())
.collect(),
);
let round_tripped: Vec<Answer> =
serde_json::from_value(value).expect("Answer list round-trips");
+679 -129
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -53,7 +53,9 @@ pub async fn record_node(
alternative: Some(alternative.to_string()),
timestamp_ms,
};
store.put(chain_hash, serde_json::to_vec(&node)?.into()).await?;
store
.put(chain_hash, serde_json::to_vec(&node)?.into())
.await?;
Ok(())
}
+38 -12
View File
@@ -9,7 +9,10 @@ use super::*;
/// 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<Storylines<SiteConfig>> {
pub async fn load_site_from_gitea(
repo_url: &str,
branch: &str,
) -> anyhow::Result<Storylines<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 {
@@ -24,8 +27,8 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul
let arity = super::variants::arity(&files)?;
let mut each = Vec::with_capacity(arity);
for n in 0..arity {
let site: SiteConfig =
serde_yaml::from_str(&super::variants::storyline(&files, n)[0].1).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
let site: SiteConfig = serde_yaml::from_str(&super::variants::storyline(&files, n)[0].1)
.map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
site.validate()?;
each.push(site);
}
@@ -40,7 +43,10 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul
#[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)?))
Ok(format!(
"{}/api/v1/repos/{owner}/{repo}/raw",
gitea_api_base(repo_url)?
))
}
#[cfg(feature = "ssr")]
@@ -114,7 +120,8 @@ pub(crate) async fn fetch_gitea_file(
pub async fn load_aggregates_from_gitea(
repo_url: &str,
branch: &str,
) -> anyhow::Result<Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>> {
) -> anyhow::Result<Storylines<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?;
@@ -123,7 +130,10 @@ pub async fn load_aggregates_from_gitea(
let files = vec![("aggregates.yaml".to_string(), raw)];
let arity = super::variants::arity(&files)?;
let each = (0..arity)
.map(|n| crate::aggregates::parse_aggregates_yaml(&super::variants::storyline(&files, n)[0].1).map(with_builtin_aggregates))
.map(|n| {
crate::aggregates::parse_aggregates_yaml(&super::variants::storyline(&files, n)[0].1)
.map(with_builtin_aggregates)
})
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(Storylines { each })
}
@@ -183,7 +193,11 @@ pub async fn load_questions_from_gitea(
#[cfg(feature = "ssr")]
pub fn same_arity(questions: usize, aggregates: usize, site: usize) -> anyhow::Result<usize> {
let arity = questions.max(aggregates).max(site);
for (what, n) in [("questions", questions), ("aggregates.yaml", aggregates), ("site.yaml", site)] {
for (what, n) in [
("questions", questions),
("aggregates.yaml", aggregates),
("site.yaml", site),
] {
if n != 1 && n != arity {
anyhow::bail!("{what} tells {n} storyline(s), but the site tells {arity}; every list of texts has the same length");
}
@@ -219,9 +233,13 @@ pub async fn watch_for_reload(
repo_url: String,
branch: String,
subdir: String,
questions: std::sync::Arc<arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, Question>>>>,
questions: std::sync::Arc<
arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, Question>>>,
>,
aggregates: std::sync::Arc<
arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>>,
arc_swap::ArcSwap<
Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>,
>,
>,
site: std::sync::Arc<arc_swap::ArcSwap<Storylines<SiteConfig>>>,
access: std::sync::Arc<arc_swap::ArcSwap<crate::access::Policy>>,
@@ -255,14 +273,23 @@ pub async fn watch_for_reload(
};
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
Ok(loaded) => {
if let Err(e) = validate_questions(&loaded, &loaded_aggregates).and_then(|_| same_arity(loaded.arity(), loaded_aggregates.arity(), loaded_site.arity())) {
if let Err(e) = validate_questions(&loaded, &loaded_aggregates).and_then(|_| {
same_arity(
loaded.arity(),
loaded_aggregates.arity(),
loaded_site.arity(),
)
}) {
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)));
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));
@@ -275,4 +302,3 @@ pub async fn watch_for_reload(
}
}
}
+115 -15
View File
@@ -49,15 +49,56 @@ pub struct GiteaRepoQuery {
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 {
serve_repo_file(&state, &path, false).await
}
/// `GET /.well-known/{*path}` - the content repo overlaid on the one part
/// of the URL space that can never collide with a question: RFC 8615
/// reserves `/.well-known/`, and a question's id is derived from a file
/// name under `questions/`, which cannot begin with a dot. A repo ships
/// `.well-known/security.txt`, `.well-known/assetlinks.json`,
/// `.well-known/matrix/server` and so on at the path they are served at,
/// and a miss is a real 404 rather than the app shell answering 200 with
/// an HTML page - which is what a client probing for one of these would
/// otherwise have to parse.
///
/// Paths Caddy claims in front of portal (mail autoconfiguration, the
/// CalDAV and CardDAV redirects) never reach here.
#[cfg(feature = "ssr")]
pub async fn well_known_handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
axum::extract::Path(path): axum::extract::Path<String>,
) -> axum::response::Response {
serve_repo_file(&state, &format!(".well-known/{path}"), true).await
}
/// Proxies one repo-relative file from the content repo's default branch.
/// `well_known` only changes what an extensionless file is typed as: under
/// `/.well-known/` those are JSON by convention (`matrix/server`,
/// `nodeinfo`), while elsewhere a file with no extension is an unknown.
#[cfg(feature = "ssr")]
async fn serve_repo_file(
state: &crate::server::AppState,
path: &str,
well_known: bool,
) -> axum::response::Response {
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
if !is_safe_site_path(&path) {
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()) {
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}");
@@ -79,12 +120,26 @@ pub async fn site_asset_handler(
Some("avif") => "image/avif",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2",
Some("txt") => "text/plain; charset=utf-8",
Some("xml") => "application/xml; charset=utf-8",
Some("webmanifest") => "application/manifest+json",
// An Apple configuration profile only offers to install when it
// arrives under this type; anything else is just a download.
Some("mobileconfig") => "application/x-apple-aspen-config",
// A file with no extension at all: rsplit yields the whole name.
_ if well_known && !path.rsplit('/').next().unwrap_or_default().contains('.') => {
"application/json; charset=utf-8"
}
_ => "application/octet-stream",
};
// Photographs are large and do not change under a name, so a
// browser may keep them for an hour; everything else revalidates,
// so a content push shows on reload.
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") { "public, max-age=3600" } else { "no-cache" };
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") {
"public, max-age=3600"
} else {
"no-cache"
};
(
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
bytes,
@@ -114,14 +169,22 @@ pub async fn gitea_repo_handler(
.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()));
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}")))?;
.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,
@@ -168,27 +231,38 @@ pub async fn automation_kv_handler(
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("");
if expected.is_empty() || presented != expected {
return Err((axum::http::StatusCode::UNAUTHORIZED, "unauthorized".to_string()));
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::Principal::Service {
name: "automation".to_string(),
},
crate::access::Action::AutomationRead,
&crate::access::Resource::Bucket { name: bucket.clone() },
&crate::access::Resource::Bucket {
name: bucket.clone(),
},
serde_json::json!({}),
);
if !decision.allow {
return Err((axum::http::StatusCode::FORBIDDEN, "not authorized".to_string()));
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}")))?;
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
@@ -209,3 +283,29 @@ pub async fn automation_kv_handler(
Ok(axum::Json(serde_json::Value::Array(items)))
}
#[cfg(test)]
mod tests {
use super::*;
/// The overlay's whole safety argument: `/.well-known/` is reserved by
/// RFC 8615 and a question's id comes from a file under `questions/`, so
/// a repo file served at the site root cannot shadow a page - and the
/// path it is fetched by still cannot climb out of the repo.
#[test]
fn well_known_paths_stay_inside_the_repo() {
for path in [
".well-known/security.txt",
".well-known/matrix/server",
".well-known/assetlinks.json",
] {
assert!(is_safe_site_path(path), "{path} should be servable");
}
for path in [
".well-known/../questions/index.yaml",
".well-known//escape",
"/.well-known/security.txt",
] {
assert!(!is_safe_site_path(path), "{path} should be refused");
}
}
}
+20 -7
View File
@@ -118,7 +118,6 @@ pub(crate) fn rewrite_img_tag(tag: &str) -> String {
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
}
/// Markdown that a person wrote into a record - a bio on a card - as
/// opposed to the site's own content. Paragraphs, line breaks as they
/// were typed, lists, emphasis and links; no images (a picture has its
@@ -147,7 +146,9 @@ pub fn render_written_markdown(text: &str) -> String {
// What someone typed on a new line is on a new line.
Event::SoftBreak => Some(Event::HardBreak),
Event::Start(Tag::Link { dest_url, .. })
if !(dest_url.starts_with("https://") || dest_url.starts_with("mailto:") || dest_url.starts_with("tel:")) =>
if !(dest_url.starts_with("https://")
|| dest_url.starts_with("mailto:")
|| dest_url.starts_with("tel:")) =>
{
in_dropped_link += 1;
None
@@ -160,7 +161,8 @@ pub fn render_written_markdown(text: &str) -> String {
});
let mut out = String::new();
html::push_html(&mut out, filtered);
out.trim().replace("<a href=", "<a rel=\"nofollow ugc\" href=")
out.trim()
.replace("<a href=", "<a rel=\"nofollow ugc\" href=")
}
#[cfg(test)]
@@ -171,14 +173,25 @@ mod written_tests {
fn a_bio_keeps_what_a_person_would_write_and_nothing_that_reaches_out() {
assert_eq!(md("Line one\nline two"), "<p>Line one<br />\nline two</p>");
assert!(md("- NS 3424\n- NS 3423").contains("<ul>\n<li>NS 3424</li>"));
assert!(md("**Sivilingeniør**, *NTNU*").contains("<strong>Sivilingeniør</strong>, <em>NTNU</em>"));
assert_eq!(md("[Westra](https://westra.no)"), "<p><a rel=\"nofollow ugc\" href=\"https://westra.no\">Westra</a></p>");
assert!(md("**Sivilingeniør**, *NTNU*")
.contains("<strong>Sivilingeniør</strong>, <em>NTNU</em>"));
assert_eq!(
md("[Westra](https://westra.no)"),
"<p><a rel=\"nofollow ugc\" href=\"https://westra.no\">Westra</a></p>"
);
// Everything that would put something else in the page is gone.
assert_eq!(md("<script>alert(1)</script>"), "");
assert_eq!(md("hi <img src=x onerror=alert(1)>"), "<p>hi </p>");
assert_eq!(md("[click](javascript:alert(1))"), "<p>click</p>");
assert_eq!(md("[home](/kontor)"), "<p>home</p>", "a site path is the site's to link, not a bio's");
assert_eq!(md("before ![pixel](https://tracker.example/p.gif) after"), "<p>before after</p>");
assert_eq!(
md("[home](/kontor)"),
"<p>home</p>",
"a site path is the site's to link, not a bio's"
);
assert_eq!(
md("before ![pixel](https://tracker.example/p.gif) after"),
"<p>before after</p>"
);
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
}
}
+343 -83
View File
@@ -13,19 +13,19 @@ use serde::{Deserialize, Serialize};
// Loading is server-only; the rest is shared with the browser build.
#[cfg(feature = "ssr")]
mod gitea;
#[cfg(feature = "ssr")]
mod variants;
mod handlers;
mod markdown;
mod validate;
#[cfg(feature = "ssr")]
mod variants;
#[cfg(feature = "ssr")]
pub use gitea::*;
#[cfg(feature = "ssr")]
pub use variants::*;
pub use handlers::*;
pub use markdown::*;
pub use validate::*;
#[cfg(feature = "ssr")]
pub use variants::*;
/// One page: a prompt plus the paths on from it. The `questions/`
/// directory tree IS the URL tree (`index.yaml` names its directory,
@@ -129,7 +129,10 @@ impl Place {
pub fn validate(&self) -> Result<(), String> {
if !(-90.0..=90.0).contains(&self.lat) || !(-180.0..=180.0).contains(&self.lon) {
return Err(format!("lat {} / lon {} is not a point on Earth", self.lat, self.lon));
return Err(format!(
"lat {} / lon {} is not a point on Earth",
self.lat, self.lon
));
}
if !(0.0..=22.0).contains(&self.zoom) {
return Err(format!("zoom {} is outside 0 to 22", self.zoom));
@@ -158,13 +161,23 @@ pub fn parse_duration_ms(text: &str) -> Result<i64, String> {
let mut total: i64 = 0;
let mut seen = false;
for token in text.split_whitespace() {
let (num, unit) = token.split_at(token.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len());
let n: i64 = num.parse().map_err(|_| format!("bad duration {text:?}: {token:?}"))?;
let (num, unit) = token.split_at(
token
.trim_end_matches(|c: char| c.is_ascii_alphabetic())
.len(),
);
let n: i64 = num
.parse()
.map_err(|_| format!("bad duration {text:?}: {token:?}"))?;
let per = match unit {
"m" => 60_000,
"h" => 3_600_000,
"d" => 86_400_000,
_ => return Err(format!("bad duration {text:?}: unit {unit:?} (use m, h, d)")),
_ => {
return Err(format!(
"bad duration {text:?}: unit {unit:?} (use m, h, d)"
))
}
};
total += n * per;
seen = true;
@@ -228,7 +241,9 @@ pub fn fill_from_user(question: &mut Question, user: Option<&crate::auth::User>)
for alt in &mut question.alternatives {
for feature in &mut alt.features {
for req in &mut feature.requirements {
let Some(value) = req.value.as_deref() else { continue };
let Some(value) = req.value.as_deref() else {
continue;
};
let filled = match value {
"{user.email}" => user.and_then(|u| u.email.clone()),
"{user.name}" => user.map(|u| u.display_name.clone()),
@@ -274,7 +289,13 @@ pub fn template_action(action: &str, responses: &serde_json::Value) -> Option<St
// A URL segment: keep it to what a segment can carry.
let safe: String = scalar
.chars()
.map(|c| if c.is_ascii_alphanumeric() || "-_.".contains(c) { c } else { '-' })
.map(|c| {
if c.is_ascii_alphanumeric() || "-_.".contains(c) {
c
} else {
'-'
}
})
.collect();
out.push_str(&safe);
rest = &rest[end + 1..];
@@ -519,6 +540,18 @@ pub struct ResourceSpec {
/// it where the silence needs a voice ("Nothing released yet").
#[serde(default)]
pub empty: Option<String>,
/// Show at most this many items, applied after `jq`. For a feed
/// where only the newest matters: a releases card wants the cut you
/// should run, not every cut there has ever been, and a page that
/// grows by one card per release gets longer forever.
///
/// After `jq` rather than before, so it caps what the page will
/// actually render whatever the filter did — and so content can
/// reorder with jq and still take the top of its own ordering. A
/// source that yields a single record has no length and passes
/// through untouched. `None` shows everything.
#[serde(default)]
pub limit: Option<usize>,
}
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket
@@ -527,18 +560,27 @@ pub struct ResourceSpec {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResourceSource {
Kv { bucket: String },
GiteaStarred { username: String },
Kv {
bucket: String,
},
GiteaStarred {
username: String,
},
/// A repo's releases - fetched with `GITEA_API_TOKEN`, so it works
/// for private repos too (shape the link with jq: a private
/// release's html_url 404s for anonymous visitors, so map it to
/// null unless the repo is public).
GiteaReleases { owner: String, repo: String },
GiteaReleases {
owner: String,
repo: String,
},
/// Any other HTTPS JSON endpoint. Scheme-restricted and checked
/// against loopback/private/link-local addresses at fetch time
/// (`resource::fetch_url_resource`) - a server-side fetch of a
/// content-supplied URL is SSRF surface, so it fails closed.
Url { url: String },
Url {
url: String,
},
}
impl ResourceSpec {
@@ -814,6 +856,28 @@ pub struct SiteConfig {
/// skipped, never half-sent (see `mail.rs`).
#[serde(default)]
pub mail: Option<MailConfig>,
/// Where `/profile.mobileconfig` gets its values. Absent means the
/// site offers no configuration profile and that route is a 404.
#[serde(default)]
pub profile: Option<ProfileConfig>,
}
/// An Apple configuration profile rendered from the mail server's own PACC
/// document - see `profile.rs`. Nothing here describes a server: `pacc` says
/// where the description already lives, so hosts and ports are not written
/// down a second time to drift from the first.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ProfileConfig {
/// The PACC document, normally
/// `https://<mail host>/.well-known/user-agent-configuration.json`.
pub pacc: String,
/// What the accounts are called on the device. Defaults to the site's
/// own host.
#[serde(default)]
pub name: Option<String>,
/// Shown as the profile's publisher. Defaults to `name`.
#[serde(default)]
pub organization: Option<String>,
}
/// The sender identity, and the one built-in mail's wording.
@@ -855,7 +919,10 @@ pub struct HeroConfig {
impl Default for HeroConfig {
fn default() -> Self {
Self { kind: default_hero_kind(), module: None }
Self {
kind: default_hero_kind(),
module: None,
}
}
}
@@ -895,6 +962,14 @@ impl SiteConfig {
);
}
}
if let Some(profile) = &self.profile {
if !profile.pacc.starts_with("https://") {
anyhow::bail!(
"site.yaml: profile.pacc {:?} must be an https:// URL (the PACC document the profile is built from)",
profile.pacc
);
}
}
if let Some(icon) = &self.favicon {
let ok = [".svg", ".png", ".ico"].iter().any(|e| icon.ends_with(e));
if !is_safe_site_path(icon) || !ok {
@@ -1072,7 +1147,12 @@ pub fn earned_followups(
queue.push_back(q);
}
while let Some(q) = queue.pop_front() {
for action in q.alternatives.iter().filter(|a| asks_nothing(a)).filter_map(|a| a.action.as_deref()) {
for action in q
.alternatives
.iter()
.filter(|a| asks_nothing(a))
.filter_map(|a| a.action.as_deref())
{
let Some(id) = action_target_id(questions, action) else {
continue;
};
@@ -1165,9 +1245,13 @@ pub fn build_questions(
/// Every storyline of the content, each a full map of questions.
#[cfg(feature = "ssr")]
pub fn build_storylines(files: &[(String, String)]) -> anyhow::Result<variants::Storylines<std::collections::HashMap<String, Question>>> {
pub fn build_storylines(
files: &[(String, String)],
) -> anyhow::Result<variants::Storylines<std::collections::HashMap<String, Question>>> {
let arity = variants::arity(files)?;
let each = (0..arity).map(|n| build_one_storyline(&variants::storyline(files, n))).collect::<anyhow::Result<Vec<_>>>()?;
let each = (0..arity)
.map(|n| build_one_storyline(&variants::storyline(files, n)))
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(variants::Storylines { each })
}
@@ -1183,8 +1267,8 @@ fn build_one_storyline(
if name != "_section.yaml" {
continue;
}
let mut section: SectionConfig = serde_yaml::from_str(raw)
.map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?;
let mut section: SectionConfig =
serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?;
let dir = dir_from_path(rel);
if let Some(rc) = &section.requires_chain {
section.requires_chain = Some(resolve_ref(&dir, rc));
@@ -1289,7 +1373,9 @@ aggregates:
.unwrap()
}
fn question_with_transitions(transitions_yaml: &str) -> std::collections::HashMap<String, Question> {
fn question_with_transitions(
transitions_yaml: &str,
) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
r#"
id: /t
@@ -1309,7 +1395,6 @@ alternatives:
std::collections::HashMap::from([(question.id.clone(), question)])
}
fn question_with_record(field_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
r#"
@@ -1323,6 +1408,7 @@ alternatives:
public: true
source: {{ kind: kv, bucket: things }}
- name: Object
action: /object
record_as: things
features:
- name: Yours
@@ -1343,9 +1429,13 @@ alternatives:
let good = question_with_record(" - { name: case, type: record, of: things }");
validate_questions(&good, &schema_fixture()).unwrap();
let nowhere = question_with_record(" - { name: case, type: record, of: nothing_declared }");
let nowhere =
question_with_record(" - { name: case, type: record, of: nothing_declared }");
let err = validate_questions(&nowhere, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("has no aggregate declared"), "{err}");
assert!(
err.to_string().contains("has no aggregate declared"),
"{err}"
);
let unsaid = question_with_record(" - { name: case, type: record }");
let err = validate_questions(&unsaid, &schema_fixture()).unwrap_err();
@@ -1354,12 +1444,19 @@ alternatives:
// `of` means one thing, so it belongs to one type.
let stray = question_with_record(" - { name: case, of: things }");
let err = validate_questions(&stray, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("only a type: record field has"), "{err}");
assert!(
err.to_string().contains("only a type: record field has"),
"{err}"
);
// The id arrives on the link; it is not looked up.
let bound = question_with_record(" - name: case\n type: record\n of: things\n bind:\n field: what\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }");
let err = validate_questions(&bound, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("the link it came in on carries the id"), "{err}");
assert!(
err.to_string()
.contains("the link it came in on carries the id"),
"{err}"
);
}
// A state that grants a group makes an account for someone, so the
@@ -1371,7 +1468,9 @@ alternatives:
)
.unwrap();
let asks = question_with_record(" - { name: name }\n - { name: email, type: email }");
let asks = question_with_record(
" - { name: name }\n - { name: email, type: email }",
);
validate_questions(&asks, &granting).unwrap();
let no_name = question_with_record(" - { name: email, type: email }");
@@ -1394,7 +1493,10 @@ alternatives:
let undeclared = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions: {}\n",
);
assert!(undeclared.unwrap_err().to_string().contains("not a declared transition"));
assert!(undeclared
.unwrap_err()
.to_string()
.contains("not a declared transition"));
}
#[test]
@@ -1420,6 +1522,7 @@ id: /t
name: Where is it?
alternatives:
- name: A
action: /t
features:
- name: Where
requirements:
@@ -1444,7 +1547,10 @@ alternatives:
" bind:\n field: other\n param: p\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }",
);
let err = validate_questions(&bound, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("the visitor puts the pin"), "{err}");
assert!(
err.to_string().contains("the visitor puts the pin"),
"{err}"
);
}
#[test]
@@ -1454,7 +1560,13 @@ alternatives:
let no_zoom = question_with_location(" value: \"59.8938,11.0332\"");
validate_questions(&no_zoom, &schema_fixture()).unwrap();
for bad in ["\"59.8938\"", "\"91,11\"", "\"59,200\"", "\"here\"", "\"59,11,15,3\""] {
for bad in [
"\"59.8938\"",
"\"91,11\"",
"\"59,200\"",
"\"here\"",
"\"59,11,15,3\"",
] {
let questions = question_with_location(&format!(" value: {bad}"));
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("opens the map"), "{bad}: {err}");
@@ -1463,7 +1575,12 @@ alternatives:
#[test]
fn a_page_about_a_place_says_where_and_how_close() {
let place = Place { lat: 59.8938, lon: 11.0332, zoom: 15.0, name: Some("Tomter".into()) };
let place = Place {
lat: 59.8938,
lon: 11.0332,
zoom: 15.0,
name: Some("Tomter".into()),
};
place.validate().unwrap();
assert_eq!(place.as_value(), "59.8938,11.0332,15");
// The default is a street and its neighbours, not the globe.
@@ -1472,10 +1589,30 @@ alternatives:
assert!(bare.name.is_none());
for bad in [
Place { lat: 91.0, lon: 11.0, zoom: 15.0, name: None },
Place { lat: 59.0, lon: 181.0, zoom: 15.0, name: None },
Place { lat: 59.0, lon: 11.0, zoom: 30.0, name: None },
Place { lat: 59.0, lon: 11.0, zoom: 15.0, name: Some(" ".into()) },
Place {
lat: 91.0,
lon: 11.0,
zoom: 15.0,
name: None,
},
Place {
lat: 59.0,
lon: 181.0,
zoom: 15.0,
name: None,
},
Place {
lat: 59.0,
lon: 11.0,
zoom: 30.0,
name: None,
},
Place {
lat: 59.0,
lon: 11.0,
zoom: 15.0,
name: Some(" ".into()),
},
] {
assert!(bad.validate().is_err(), "{bad:?}");
}
@@ -1483,7 +1620,8 @@ alternatives:
#[test]
fn undeclared_from_state_is_rejected() {
let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }");
let questions =
question_with_transitions(" - { from: bogus, to: middle, label: X }");
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("transition.from"));
}
@@ -1492,19 +1630,23 @@ alternatives:
fn undeclared_edge_is_rejected() {
// Both states exist, but open -> done skips a step the graph
// never declared.
let questions = question_with_transitions(" - { from: open, to: done, label: Skip }");
let questions =
question_with_transitions(" - { from: open, to: done, label: Skip }");
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("not a declared edge"));
}
#[test]
fn dangling_action_is_rejected() {
let question: Question =
serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /nowhere\n")
.unwrap();
let question: Question = serde_yaml::from_str(
"id: /a\nname: A\nalternatives:\n - name: Go\n action: /nowhere\n",
)
.unwrap();
let questions = std::collections::HashMap::from([(question.id.clone(), question)]);
let err = validate_questions(&questions, &Default::default()).unwrap_err();
assert!(err.to_string().contains("does not match any declared question id"));
assert!(err
.to_string()
.contains("does not match any declared question id"));
}
#[test]
@@ -1523,6 +1665,7 @@ id: /b
name: B
alternatives:
- name: A
action: /b
features:
- name: ""
requirements:
@@ -1564,7 +1707,7 @@ alternatives:
fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
"id: /g\nname: G\nalternatives:\n - name: A\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
"id: /g\nname: G\nalternatives:\n - name: A\n action: /g\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
@@ -1591,7 +1734,9 @@ alternatives:
" - { name: email, type: email, relay: \"wss://relay.redoal.com\" }",
);
let err = validate_questions(&questions, &Default::default()).unwrap_err();
assert!(err.to_string().contains("relay only makes sense on type: gesture"));
assert!(err
.to_string()
.contains("relay only makes sense on type: gesture"));
}
#[test]
@@ -1630,7 +1775,8 @@ alternatives:
let absolute: SiteConfig =
serde_yaml::from_str("hero: { kind: module, module: https://x/y.js }").unwrap();
assert!(absolute.validate().is_err());
let ok: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap();
let ok: SiteConfig =
serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap();
assert!(ok.validate().is_ok());
}
@@ -1650,7 +1796,10 @@ alternatives:
assert_eq!(route_from_path("index.yaml"), "/");
assert_eq!(route_from_path("applied.yaml"), "/applied");
assert_eq!(route_from_path("develop/index.yaml"), "/develop");
assert_eq!(route_from_path("develop/proposal.yaml"), "/develop/proposal");
assert_eq!(
route_from_path("develop/proposal.yaml"),
"/develop/proposal"
);
assert_eq!(route_from_path("review/[record].yaml"), "/review/[record]");
}
@@ -1665,8 +1814,10 @@ alternatives:
#[test]
fn dynamic_patterns_capture_segments() {
assert_eq!(path_matches("/review/[record]", "/review/abc"),
Some(vec![("record".into(), "abc".into())]));
assert_eq!(
path_matches("/review/[record]", "/review/abc"),
Some(vec![("record".into(), "abc".into())])
);
assert_eq!(path_matches("/review/[record]", "/review"), None);
assert_eq!(path_matches("/review/[record]", "/other/abc"), None);
assert_eq!(path_matches("/review", "/review"), Some(vec![]));
@@ -1675,25 +1826,55 @@ alternatives:
#[test]
fn tree_becomes_router_with_inference_and_sections() {
let files = vec![
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: go\n action: applied\n".to_string()),
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
("develop/index.yaml".to_string(), "name: Develop\nalternatives:\n - name: propose\n action: proposal\n".to_string()),
("develop/proposal.yaml".to_string(), "name: Proposal\nalternatives:\n - name: send\n action: proposed\n".to_string()),
("develop/proposed.yaml".to_string(), "name: Proposed\n".to_string()),
("review/_section.yaml".to_string(), "qualifies: portal_owners\n".to_string()),
("review/index.yaml".to_string(), "name: Review\n".to_string()),
("review/_draft.yaml".to_string(), "not even valid yaml: [\n".to_string()),
(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: go\n action: applied\n".to_string(),
),
(
"applied.yaml".to_string(),
"name: Applied\nfollowup: true\n".to_string(),
),
(
"develop/index.yaml".to_string(),
"name: Develop\nalternatives:\n - name: propose\n action: proposal\n"
.to_string(),
),
(
"develop/proposal.yaml".to_string(),
"name: Proposal\nalternatives:\n - name: send\n action: proposed\n".to_string(),
),
(
"develop/proposed.yaml".to_string(),
"name: Proposed\n".to_string(),
),
(
"review/_section.yaml".to_string(),
"qualifies: portal_owners\n".to_string(),
),
(
"review/index.yaml".to_string(),
"name: Review\n".to_string(),
),
(
"review/_draft.yaml".to_string(),
"not even valid yaml: [\n".to_string(),
),
];
let questions = build_questions(&files).unwrap();
// Derived ids and resolved relative actions.
assert_eq!(questions["/"].alternatives[0].action.as_deref(), Some("/applied"));
assert_eq!(
questions["/"].alternatives[0].action.as_deref(),
Some("/applied")
);
assert_eq!(
questions["/develop"].alternatives[0].action.as_deref(),
Some("/develop/proposal")
);
assert_eq!(
questions["/develop/proposal"].alternatives[0].action.as_deref(),
questions["/develop/proposal"].alternatives[0]
.action
.as_deref(),
Some("/develop/proposed")
);
@@ -1705,7 +1886,10 @@ alternatives:
assert!(questions["/develop/proposal"].is_followup());
// Section inheritance gates the directory; drafts are skipped.
assert_eq!(questions["/review"].qualifies.as_deref(), Some("portal_owners"));
assert_eq!(
questions["/review"].qualifies.as_deref(),
Some("portal_owners")
);
assert_eq!(questions["/"].qualifies, None);
assert!(!questions.contains_key("/review/_draft"));
}
@@ -1736,7 +1920,10 @@ alternatives:
};
// Templated actions resolve to the dynamic page's pattern.
assert_eq!(action_target_id(&questions, "/shape/{curve.key}").as_deref(), Some("/shape/[key]"));
assert_eq!(
action_target_id(&questions, "/shape/{curve.key}").as_deref(),
Some("/shape/[key]")
);
assert_eq!(action_target_id(&questions, "/nowhere"), None);
// No chain, no followups: standing on the front door earns none
@@ -1747,9 +1934,18 @@ alternatives:
assert_eq!(earned("/applied", &[]), set(&[]));
// A verified answer earns exactly where it sent the visitor.
assert_eq!(unlocked_by(&questions, "/", Some("apply")), vec!["/applied"]);
assert_eq!(unlocked_by(&questions, "/", Some("draw")), vec!["/shape/[key]"]);
assert_eq!(unlocked_by(&questions, "/kontakt", Some("send")), vec!["/takk"]);
assert_eq!(
unlocked_by(&questions, "/", Some("apply")),
vec!["/applied"]
);
assert_eq!(
unlocked_by(&questions, "/", Some("draw")),
vec!["/shape/[key]"]
);
assert_eq!(
unlocked_by(&questions, "/kontakt", Some("send")),
vec!["/takk"]
);
assert_eq!(earned("/applied", &["/applied"]), set(&["/applied"]));
// An old node without the alternative: the answered question's
// recording alternatives, and nothing beyond that question.
@@ -1761,7 +1957,10 @@ alternatives:
// "watch" records an answer, so /subscribed is not earned by
// having drawn a shape; "back" leads to the front door, which
// is never a followup and ends the walk.
assert_eq!(earned("/shape/abc", &["/shape/[key]"]), set(&["/shape/[key]"]));
assert_eq!(
earned("/shape/abc", &["/shape/[key]"]),
set(&["/shape/[key]"])
);
assert_eq!(earned("/takk", &["/takk"]), set(&["/takk"]));
// The email-link page is earned by nothing on the site.
assert!(!earned("/", &["/applied"]).contains("/watching"));
@@ -1802,7 +2001,12 @@ alternatives:
let page = resolve_question(&questions, "/review/abc123").unwrap();
assert_eq!(page.id, "/review/abc123");
assert_eq!(
page.alternatives[0].features[0].resource.as_ref().unwrap().key.as_deref(),
page.alternatives[0].features[0]
.resource
.as_ref()
.unwrap()
.key
.as_deref(),
Some("abc123")
);
assert!(resolve_question(&questions, "/review").is_none());
@@ -1820,7 +2024,10 @@ alternatives:
),
];
let questions = build_questions(&files).unwrap();
assert_eq!(questions["/shaped"].requires_chain.as_deref(), Some("/shape"));
assert_eq!(
questions["/shaped"].requires_chain.as_deref(),
Some("/shape")
);
assert!(validate_questions(&questions, &Default::default()).is_ok());
let dangling = vec![(
@@ -1835,7 +2042,8 @@ alternatives:
fn recorded_buckets_must_be_attended() {
let write_only = vec![(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(),
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: black_hole\n"
.to_string(),
)];
let questions = build_questions(&write_only).unwrap();
let err = validate_questions(&questions, &Default::default()).unwrap_err();
@@ -1846,7 +2054,7 @@ alternatives:
let with_desk = vec![
(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(),
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: inbox\n".to_string(),
),
(
"review/index.yaml".to_string(),
@@ -1870,9 +2078,13 @@ alternatives:
let files = vec![
(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: go\n action: /review/[record]\n".to_string(),
"name: Home\nalternatives:\n - name: go\n action: /review/[record]\n"
.to_string(),
),
(
"review/[record].yaml".to_string(),
"name: Record\n".to_string(),
),
("review/[record].yaml".to_string(), "name: Record\n".to_string()),
];
let questions = build_questions(&files).unwrap();
assert!(validate_questions(&questions, &Default::default()).is_err());
@@ -1915,7 +2127,9 @@ alternatives:
.unwrap();
let mut qs = std::collections::HashMap::new();
qs.insert(bad.id.clone(), bad);
let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string();
let err = validate_questions(&qs, &Default::default())
.unwrap_err()
.to_string();
assert!(err.contains("bad starts"), "{err}");
let dynamic: Question = serde_yaml::from_str(
@@ -1924,7 +2138,9 @@ alternatives:
.unwrap();
let mut qs = std::collections::HashMap::new();
qs.insert(dynamic.id.clone(), dynamic);
let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string();
let err = validate_questions(&qs, &Default::default())
.unwrap_err()
.to_string();
assert!(err.contains("dynamic page can't carry an event"), "{err}");
}
@@ -1933,14 +2149,20 @@ alternatives:
let merged = with_builtin_aggregates(Default::default());
let schema = &merged[EVENTS_BUCKET];
assert_eq!(schema.initial, "announced");
assert_eq!(schema.allowed("announced"), ["awaiting_summary", "summarized"]);
assert_eq!(
schema.allowed("announced"),
["awaiting_summary", "summarized"]
);
assert_eq!(schema.allowed("awaiting_summary"), ["summarized"]);
let content = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: portal_events\n initial: mine\n states:\n mine: { event: mine }\n",
)
.unwrap();
assert_eq!(with_builtin_aggregates(content)[EVENTS_BUCKET].initial, "mine");
assert_eq!(
with_builtin_aggregates(content)[EVENTS_BUCKET].initial,
"mine"
);
}
#[test]
@@ -1966,20 +2188,39 @@ alternatives:
fn a_signed_in_person_is_not_asked_who_they_are() {
let yaml = "id: /oppslag/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: email\n type: email\n value: \"{user.email}\"\n - name: navn\n value: \"{user.name}\"\n - name: hva\n type: textarea\n value: \"{user.email} says\"\n";
let q: Question = serde_yaml::from_str(yaml).unwrap();
let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
let user = crate::auth::User {
sub: "s".into(),
username: "kari".into(),
display_name: "Kari N".into(),
groups: vec![],
phone: None,
email: Some("kari@vel.no".into()),
};
let mut signed_in = q.clone();
fill_from_user(&mut signed_in, Some(&user));
let reqs = &signed_in.alternatives[0].features[0].requirements;
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("hidden", Some("kari@vel.no")));
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("hidden", Some("Kari N")));
assert_eq!(
(reqs[0].kind.as_str(), reqs[0].value.as_deref()),
("hidden", Some("kari@vel.no"))
);
assert_eq!(
(reqs[1].kind.as_str(), reqs[1].value.as_deref()),
("hidden", Some("Kari N"))
);
// Mixed text is not a user field, and is left as written.
assert_eq!(reqs[2].value.as_deref(), Some("{user.email} says"));
// A visitor is asked: the placeholder goes, the field stays.
let mut visitor = q.clone();
fill_from_user(&mut visitor, None);
let reqs = &visitor.alternatives[0].features[0].requirements;
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("email", None));
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None));
assert_eq!(
(reqs[0].kind.as_str(), reqs[0].value.as_deref()),
("email", None)
);
assert_eq!(
(reqs[1].kind.as_str(), reqs[1].value.as_deref()),
("text", None)
);
}
#[test]
@@ -2025,8 +2266,14 @@ alternatives:
render_inline_markdown("![J](https://x.no/j.jpg \"wat\")"),
"<img src=\"https://x.no/j.jpg\" alt=\"J\" />"
);
assert_eq!(render_inline_markdown("[ok](/shape)"), "<a href=\"/shape\">ok</a>");
assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "<a href=\"mailto:bl@uhhm.no\">mail</a>");
assert_eq!(
render_inline_markdown("[ok](/shape)"),
"<a href=\"/shape\">ok</a>"
);
assert_eq!(
render_inline_markdown("[mail](mailto:bl@uhhm.no)"),
"<a href=\"mailto:bl@uhhm.no\">mail</a>"
);
}
#[test]
@@ -2048,7 +2295,10 @@ alternatives:
#[test]
fn action_templates_from_responses() {
let r = serde_json::json!({"curve": {"key": "20aa98", "points": [[0,0]]}, "n": 3});
assert_eq!(template_action("/shape/{curve.key}", &r).as_deref(), Some("/shape/20aa98"));
assert_eq!(
template_action("/shape/{curve.key}", &r).as_deref(),
Some("/shape/20aa98")
);
assert_eq!(template_action("/x/{n}/y", &r).as_deref(), Some("/x/3/y"));
assert_eq!(template_action("/shape", &r).as_deref(), Some("/shape"));
assert!(template_action("/shape/{curve.missing}", &r).is_none());
@@ -2066,7 +2316,12 @@ alternatives:
let mut qs = std::collections::HashMap::new();
qs.insert(q.id.clone(), q);
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
assert_eq!(page.alternatives[0].features[0].requirements[0].value.as_deref(), Some("20aa98"));
assert_eq!(
page.alternatives[0].features[0].requirements[0]
.value
.as_deref(),
Some("20aa98")
);
let q: Question = serde_yaml::from_str(
"id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n resource:\n source: { kind: url, url: \"https://relay.example/place/{key}\" }\n public: true\n",
@@ -2075,7 +2330,12 @@ alternatives:
let mut qs = std::collections::HashMap::new();
qs.insert(q.id.clone(), q);
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
match &page.alternatives[0].features[0].resource.as_ref().unwrap().source {
match &page.alternatives[0].features[0]
.resource
.as_ref()
.unwrap()
.source
{
ResourceSource::Url { url } => assert_eq!(url, "https://relay.example/place/20aa98"),
_ => panic!(),
}
+87 -19
View File
@@ -107,12 +107,18 @@ pub fn with_builtin_aggregates(
/// 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 {
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))
a.features.iter().any(|f| {
f.resource
.as_ref()
.and_then(|r| r.bucket())
.is_some_and(|b| b == bucket)
})
})
})
}
@@ -125,12 +131,21 @@ pub fn validate_questions(
// 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();
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())) {
for alternative in question
.alternatives
.iter()
.filter(|a| a.record_as.as_deref() == Some(bucket.as_str()))
{
let collects_email = alternative
.features
.iter()
@@ -154,10 +169,20 @@ pub fn validate_questions(
anyhow::bail!("question {:?}: slideshow has no slides", question.id);
}
if !(20..=100).contains(&show.max_height) {
anyhow::bail!("question {:?}: slideshow max_height is {} svh; between 20 and 100", question.id, show.max_height);
anyhow::bail!(
"question {:?}: slideshow max_height is {} svh; between 20 and 100",
question.id,
show.max_height
);
}
for slide in &show.slides {
let ok = slide.image.starts_with("https://") || crate::content::is_safe_site_path(slide.image.trim_start_matches("/site/").trim_start_matches('/'));
let ok = slide.image.starts_with("https://")
|| crate::content::is_safe_site_path(
slide
.image
.trim_start_matches("/site/")
.trim_start_matches('/'),
);
if !ok {
anyhow::bail!("question {:?}: slideshow image {:?} is neither an https address nor a path in the content repo", question.id, slide.image);
}
@@ -171,10 +196,20 @@ pub fn validate_questions(
// has nowhere to go.
for (bucket, schema) in aggregates {
for (state, spec) in &schema.mail_for_state {
let Some(parent) = crate::mail::parent_of(&spec.to) else { continue };
let Some(parent) = crate::mail::parent_of(&spec.to) else {
continue;
};
for question in questions.values() {
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
let has = alternative.features.iter().flat_map(|f| &f.requirements).any(|r| r.name == parent && r.kind == "record" && r.of.is_some());
for alternative in question
.alternatives
.iter()
.filter(|a| a.record_as.as_deref() == Some(bucket.as_str()))
{
let has = alternative
.features
.iter()
.flat_map(|f| &f.requirements)
.any(|r| r.name == parent && r.kind == "record" && r.of.is_some());
if !has {
anyhow::bail!(
"question {:?} alternative {:?}: records into {bucket:?}, whose state {state:?} mails {:?}, so it must carry a `type: record` field called {parent:?} with `of`",
@@ -199,7 +234,11 @@ pub fn validate_questions(
}
granting.sort();
for question in questions.values() {
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
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
@@ -220,7 +259,10 @@ pub fn validate_questions(
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);
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);
@@ -361,10 +403,12 @@ pub fn validate_questions(
question.id, alternative.name, feature.name, requirement.name, requirement.kind
);
}
validate_relay_url(relay).map_err(|e| anyhow::anyhow!(
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())
@@ -388,8 +432,12 @@ pub fn validate_questions(
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));
&& 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:?}",
@@ -468,7 +516,11 @@ pub fn validate_questions(
question.id, alternative.name, feature.name, transition.to, bucket
);
}
if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) {
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
@@ -512,6 +564,23 @@ pub fn validate_questions(
}
for question in questions.values() {
for alternative in &question.alternatives {
// A form is sent with its button, and the button is only
// drawn for an alternative that says where to go afterwards:
// without `action` the person fills it in and cannot send it.
let asks = alternative
.features
.iter()
.any(|f| f.requirements.iter().any(|r| r.kind != "hidden"));
if (asks || alternative.invite.is_some() || alternative.record_as.is_some())
&& alternative.action.is_none()
&& alternative.self_transition.is_none()
{
anyhow::bail!(
"question {:?} alternative {:?}: it has a form to send but no `action`, so no button is drawn; give it `action:`, the page to go to once it is sent (its own page is fine)",
question.id,
alternative.name
);
}
if alternative.one_per_person && alternative.record_as.is_none() {
anyhow::bail!(
"question {:?} alternative {:?}: one_per_person keeps each person's own record, and there is no record_as to keep it in",
@@ -535,4 +604,3 @@ pub fn validate_questions(
}
Ok(())
}
+83 -17
View File
@@ -31,7 +31,16 @@
use serde_yaml::Value;
/// Keys whose value is one string to a person.
const TEXT: &[&str] = &["name", "description", "label", "placeholder", "subject", "body", "empty", "title"];
const TEXT: &[&str] = &[
"name",
"description",
"label",
"placeholder",
"subject",
"body",
"empty",
"title",
];
/// Keys whose value is a list of strings to a person; their storylines
/// are a list of lists.
const TEXT_LIST: &[&str] = &["consequence", "encouragements"];
@@ -41,7 +50,12 @@ const TEXT_LIST: &[&str] = &["consequence", "encouragements"];
fn is_identifier(path: &[String], key: &str) -> bool {
// `path` ends with the key itself, then the item's index, then the
// list it is in.
key == "name" && path.iter().rev().nth(2).is_some_and(|p| p == "requirements")
key == "name"
&& path
.iter()
.rev()
.nth(2)
.is_some_and(|p| p == "requirements")
}
fn is_strings(seq: &[Value]) -> bool {
@@ -49,7 +63,11 @@ fn is_strings(seq: &[Value]) -> bool {
}
fn is_lists_of_strings(seq: &[Value]) -> bool {
!seq.is_empty() && seq.iter().all(|v| v.as_sequence().is_some_and(|s| s.iter().all(|x| x.is_string())))
!seq.is_empty()
&& seq.iter().all(|v| {
v.as_sequence()
.is_some_and(|s| s.iter().all(|x| x.is_string()))
})
}
/// The storyline counts found in a document, with where each was.
@@ -60,8 +78,18 @@ fn counts(value: &Value, path: &mut Vec<String>, out: &mut Vec<(String, usize)>)
let key = k.as_str().unwrap_or_default().to_string();
path.push(key.clone());
let variants = match v {
Value::Sequence(seq) if TEXT.contains(&key.as_str()) && !is_identifier(path, &key) && is_strings(seq) => Some(seq.len()),
Value::Sequence(seq) if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => Some(seq.len()),
Value::Sequence(seq)
if TEXT.contains(&key.as_str())
&& !is_identifier(path, &key)
&& is_strings(seq) =>
{
Some(seq.len())
}
Value::Sequence(seq)
if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) =>
{
Some(seq.len())
}
_ => None,
};
match variants {
@@ -90,8 +118,18 @@ fn pick(value: &mut Value, path: &mut Vec<String>, n: usize) {
let key = k.as_str().unwrap_or_default().to_string();
path.push(key.clone());
let chosen = match v {
Value::Sequence(seq) if TEXT.contains(&key.as_str()) && !is_identifier(path, &key) && is_strings(seq) => Some(seq[n.min(seq.len() - 1)].clone()),
Value::Sequence(seq) if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => Some(seq[n.min(seq.len() - 1)].clone()),
Value::Sequence(seq)
if TEXT.contains(&key.as_str())
&& !is_identifier(path, &key)
&& is_strings(seq) =>
{
Some(seq[n.min(seq.len() - 1)].clone())
}
Value::Sequence(seq)
if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) =>
{
Some(seq[n.min(seq.len() - 1)].clone())
}
_ => None,
};
match chosen {
@@ -118,7 +156,9 @@ fn pick(value: &mut Value, path: &mut Vec<String>, n: usize) {
pub fn arity(files: &[(String, String)]) -> anyhow::Result<usize> {
let mut found: Vec<(String, String, usize)> = Vec::new();
for (rel, raw) in files {
let Ok(value) = serde_yaml::from_str::<Value>(raw) else { continue };
let Ok(value) = serde_yaml::from_str::<Value>(raw) else {
continue;
};
let mut out = Vec::new();
counts(&value, &mut Vec::new(), &mut out);
found.extend(out.into_iter().map(|(p, n)| (rel.clone(), p, n)));
@@ -135,14 +175,19 @@ pub fn storyline(files: &[(String, String)], n: usize) -> Vec<(String, String)>
files
.iter()
.map(|(rel, raw)| {
let Ok(mut value) = serde_yaml::from_str::<Value>(raw) else { return (rel.clone(), raw.clone()) };
let Ok(mut value) = serde_yaml::from_str::<Value>(raw) else {
return (rel.clone(), raw.clone());
};
let mut out = Vec::new();
counts(&value, &mut Vec::new(), &mut out);
if out.is_empty() {
return (rel.clone(), raw.clone());
}
pick(&mut value, &mut Vec::new(), n);
(rel.clone(), serde_yaml::to_string(&value).unwrap_or_else(|_| raw.clone()))
(
rel.clone(),
serde_yaml::to_string(&value).unwrap_or_else(|_| raw.clone()),
)
})
.collect()
}
@@ -192,17 +237,32 @@ mod tests {
assert_eq!(v["name"], "Trenger naboene deg?");
assert_eq!(v["description"], "Alle trenger flere hender.");
assert_eq!(v["alternatives"][0]["name"], "Meld deg");
assert_eq!(v["alternatives"][0]["consequence"], serde_yaml::to_value(vec!["Meld meg"]).unwrap());
assert_eq!(
v["alternatives"][0]["consequence"],
serde_yaml::to_value(vec!["Meld meg"]).unwrap()
);
// A requirement's name is the stored field: left as written.
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][0]["name"], serde_yaml::to_value(vec!["navn"]).unwrap());
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][0]["label"], "Hva heter du?");
assert_eq!(
v["alternatives"][0]["features"][0]["requirements"][0]["name"],
serde_yaml::to_value(vec!["navn"]).unwrap()
);
assert_eq!(
v["alternatives"][0]["features"][0]["requirements"][0]["label"],
"Hva heter du?"
);
// Options are values people choose from and are stored: untouched.
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][1]["options"], serde_yaml::to_value(vec!["Dugnad", "Komité"]).unwrap());
assert_eq!(
v["alternatives"][0]["features"][0]["requirements"][1]["options"],
serde_yaml::to_value(vec!["Dugnad", "Komité"]).unwrap()
);
}
#[test]
fn a_site_with_no_lists_tells_one_storyline_and_a_short_list_is_refused() {
let plain = vec![("index.yaml".to_string(), "name: Hva?\nalternatives:\n - name: A\n consequence: [Send]\n".to_string())];
let plain = vec![(
"index.yaml".to_string(),
"name: Hva?\nalternatives:\n - name: A\n consequence: [Send]\n".to_string(),
)];
assert_eq!(arity(&plain).unwrap(), 1);
assert_eq!(storyline(&plain, 0)[0].1, plain[0].1);
let uneven = vec![
@@ -210,12 +270,18 @@ mod tests {
("b.yaml".to_string(), "name: [En, To, Tre]\n".to_string()),
];
let err = arity(&uneven).unwrap_err().to_string();
assert!(err.contains("b.yaml: name tells 3") && err.contains("tells 2") || err.contains("tells 3"), "{err}");
assert!(
err.contains("b.yaml: name tells 3") && err.contains("tells 2")
|| err.contains("tells 3"),
"{err}"
);
}
#[test]
fn the_first_storyline_is_what_the_machinery_reads() {
let s = Storylines { each: vec!["a", "b"] };
let s = Storylines {
each: vec!["a", "b"],
};
assert_eq!(*s, "a");
assert_eq!(*s.at(1), "b");
assert_eq!(*s.at(7), "b");
+86 -23
View File
@@ -63,8 +63,13 @@ pub fn has_left(schema: &crate::aggregates::AggregateSchema, answer: &Answer, no
pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> {
let now_ms = chrono::Utc::now().timestamp_millis();
let aggregates = state.aggregates.load();
for schema in aggregates.values().filter(|s| !s.leaves_for_state.is_empty()) {
let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else { continue };
for schema in aggregates
.values()
.filter(|s| !s.leaves_for_state.is_empty())
{
let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else {
continue;
};
let keys: Vec<String> = {
use futures::TryStreamExt;
match store.keys().await {
@@ -73,12 +78,20 @@ pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> {
}
};
for id in keys {
let Ok(Some(bytes)) = store.get(&id).await else { continue };
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
let Ok(Some(bytes)) = store.get(&id).await else {
continue;
};
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else {
continue;
};
if has_left(schema, &answer, now_ms) {
match store.delete(&id).await {
Ok(()) => tracing::info!(bucket = %schema.bucket, id, state = %answer.state, "a record left its list"),
Err(e) => tracing::warn!(bucket = %schema.bucket, id, "a record could not leave its list: {e}"),
Ok(()) => {
tracing::info!(bucket = %schema.bucket, id, state = %answer.state, "a record left its list")
}
Err(e) => {
tracing::warn!(bucket = %schema.bucket, id, "a record could not leave its list: {e}")
}
}
}
}
@@ -93,7 +106,11 @@ fn waiting_since(answer: &Answer) -> i64 {
}
/// 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> {
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())
@@ -120,13 +137,24 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
}
};
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 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,
&state.jetstream,
schema,
&id,
&target,
payload.clone(),
now_ms,
)
.await
{
@@ -143,11 +171,20 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
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, answer.storyline).await;
crate::mail::on_state(
state,
&schema.bucket,
&id,
&target,
&answer.responses,
answer.storyline,
)
.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);
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 {
@@ -175,7 +212,11 @@ mod tests {
use super::*;
fn schema(yaml: &str) -> crate::aggregates::AggregateSchema {
crate::aggregates::parse_aggregates_yaml(yaml).unwrap().into_values().next().unwrap()
crate::aggregates::parse_aggregates_yaml(yaml)
.unwrap()
.into_values()
.next()
.unwrap()
}
const HEARINGS: &str = r#"
@@ -192,9 +233,16 @@ aggregates:
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![],
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![],
finished: false,
storyline: 0,
}
}
@@ -207,7 +255,10 @@ aggregates:
let t = 1_000_000_000_000;
let mut answer = waiting("gone", t - 60 * 60 * 1000);
answer.decided_ms = Some(t);
assert!(!has_left(&s, &answer, t + 9 * 60 * 1000), "measured from entering the state, not from sending");
assert!(
!has_left(&s, &answer, t + 9 * 60 * 1000),
"measured from entering the state, not from sending"
);
assert!(has_left(&s, &answer, t + 10 * 60 * 1000));
// A state without it keeps its records.
assert!(!has_left(&s, &waiting("open", 0), t));
@@ -221,7 +272,10 @@ aggregates:
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()));
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
@@ -233,7 +287,10 @@ aggregates:
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()));
assert_eq!(
due(&s, &answer, sent + 82 * DAY_MS),
Some("no_answer".into())
);
}
#[test]
@@ -248,15 +305,21 @@ aggregates:
// 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();
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();
let err = crate::aggregates::parse_aggregates_yaml(&bad)
.unwrap_err()
.to_string();
assert!(err.contains("at least one"), "{err}");
}
}
+7 -2
View File
@@ -109,11 +109,16 @@ pub async fn load_events(
.await
.map_err(|e| anyhow::anyhow!("fetching messages: {e}"))?
{
let info = message.info().map_err(|e| anyhow::anyhow!("reading message info: {e}"))?;
let info = message
.info()
.map_err(|e| anyhow::anyhow!("reading message info: {e}"))?;
let mut event: Event = serde_json::from_slice(&message.payload)?;
event.sequence = info.stream_sequence;
events.push(event);
message.ack().await.map_err(|e| anyhow::anyhow!("acking: {e}"))?;
message
.ack()
.await
.map_err(|e| anyhow::anyhow!("acking: {e}"))?;
}
events.sort_by_key(|e| e.sequence);
+12
View File
@@ -24,6 +24,10 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("en", "sign_in") => "Sign in",
("en", "sign_out") => "Sign out",
("en", "nothing_here_yet") => "Nothing here yet.",
("en", "show_older") => "Show older",
("en", "for_this_device") => "For this device",
("en", "all_downloads") => "All downloads",
("en", "release_notes") => "release notes",
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
@@ -60,6 +64,10 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("no", "sign_in") => "Logg inn",
("no", "sign_out") => "Logg ut",
("no", "nothing_here_yet") => "Ingenting her ennå.",
("no", "show_older") => "Vis eldre",
("no", "for_this_device") => "Til denne enheten",
("no", "all_downloads") => "Alle nedlastinger",
("no", "release_notes") => "utgivelsesnotater",
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
@@ -114,6 +122,10 @@ mod tests {
"sign_in",
"sign_out",
"nothing_here_yet",
"show_older",
"for_this_device",
"all_downloads",
"release_notes",
"upload_failed",
"upload_too_large",
"upload_unavailable",
+9 -6
View File
@@ -31,15 +31,18 @@ pub mod people;
#[cfg(feature = "ssr")]
pub mod mail;
#[cfg(feature = "ssr")]
pub mod seed;
#[cfg(feature = "ssr")]
pub mod memberships;
#[cfg(feature = "ssr")]
pub mod report;
/// The lint: `portal lint`, what a content repo's CI runs (once uhhm/iris).
#[cfg(feature = "ssr")]
pub mod lint;
#[cfg(feature = "ssr")]
pub mod memberships;
/// The Apple configuration profile at `/profile.mobileconfig`.
#[cfg(feature = "ssr")]
pub mod profile;
#[cfg(feature = "ssr")]
pub mod report;
#[cfg(feature = "ssr")]
pub mod seed;
/// The trainer: `portal plan`, `portal build`, `portal report` and the
/// rest (once uhhm/portal-trainer).
#[cfg(feature = "ssr")]
+123 -25
View File
@@ -8,7 +8,9 @@
//! than compiled there - keeps that repo's CI coupling to "run a
//! static binary," not "build a Rust workspace."
use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir, load_site_from_dir};
use crate::lint::local::{
load_aggregates_from_dir, load_from_dir, load_needs_from_dir, load_site_from_dir,
};
use crate::lint::needs;
use crate::{aggregates, content};
@@ -24,6 +26,7 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
let mut skim = false;
let mut sim_out: Option<String> = None;
let mut show_access = false;
let mut findings_out: Option<String> = None;
while let Some(arg) = args.next() {
match arg.as_str() {
@@ -35,9 +38,13 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
"--skim" => skim = true,
"--needs-sim" => sim_out = args.next(),
"--access" => show_access = true,
"--findings" => findings_out = args.next(),
"--needs-score" => answers_in = args.next(),
"--min-accuracy" => {
min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy)
min_accuracy = args
.next()
.and_then(|v| v.parse().ok())
.unwrap_or(min_accuracy)
}
other => {
eprintln!("unknown argument: {other}");
@@ -56,23 +63,41 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
} else if let Some(repo_url) = repo {
// The first storyline of each, as the checks read it; the
// loaders have held every list of texts to the site's arity.
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?.at(0).clone();
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?.at(0).clone();
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir)
.await?
.at(0)
.clone();
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch)
.await?
.at(0)
.clone();
// 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?.at(0).clone();
let site = content::load_site_from_gitea(&repo_url, &branch)
.await?
.at(0)
.clone();
// Optional: a fetch failure means the repo declares no needs.
let needs_raw = crate::http::get_text(&format!("{}/needs.yaml?ref={branch}", content::gitea_raw_base(&repo_url)?), crate::http::GITEA_CAP).await.ok();
let needs_raw = crate::http::get_text(
&format!(
"{}/needs.yaml?ref={branch}",
content::gitea_raw_base(&repo_url)?
),
crate::http::GITEA_CAP,
)
.await
.ok();
(questions, aggregates_map, site, needs_raw)
} else {
eprintln!(
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--findings <out.jsonl>] [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
);
std::process::exit(2);
};
if let Err(e) = site.validate() {
eprintln!("FAIL: {e}");
write_findings(findings_out.as_deref(), &[schema_finding(&e.to_string())]);
std::process::exit(1);
}
let aggregates_map = content::with_builtin_aggregates(aggregates_map);
@@ -103,17 +128,29 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
eprintln!("FAIL: access policy does not validate: {e}");
std::process::exit(1);
}
println!("OK: access policy, {} rule(s), validates", policy.rules.len());
println!(
"OK: access policy, {} rule(s), validates",
policy.rules.len()
);
if show_access {
println!("\n{:<16} {:<44} {:<40} {}", "ACTION", "RESOURCE", "WHO", "WHEN");
println!(
"\n{:<16} {:<44} {:<40} {}",
"ACTION", "RESOURCE", "WHO", "WHEN"
);
let mut rows = policy.rules.clone();
rows.sort_by(|a, b| (&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who)));
rows.sort_by(|a, b| {
(&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who))
});
for r in rows {
println!("{:<16} {:<44} {:<40} {}", r.action, r.resource, r.who, r.when);
println!(
"{:<16} {:<44} {:<40} {}",
r.action, r.resource, r.who, r.when
);
}
println!();
}
check_needs(
findings_out.as_deref(),
needs_raw.as_deref(),
&questions,
&aggregates_map,
@@ -126,15 +163,73 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
}
Err(e) => {
eprintln!("FAIL: {e}");
// One schema error is no reason to hide the rest: the needs
// pass reads the same content and says what else is wrong,
// so a fix round can take them all at once.
let mut all = vec![schema_finding(&e.to_string())];
if let Some(file) = needs_raw.as_deref().and_then(|raw| needs::parse(raw).ok()) {
let findings = needs::check(&file, &questions, &aggregates_map);
print_findings(&findings);
all.extend(findings);
}
write_findings(findings_out.as_deref(), &all);
std::process::exit(1);
}
}
}
/// A content error the schema pass stopped at, as a finding: the repo
/// is its scope and the message its subject, since it has no other.
fn schema_finding(message: &str) -> needs::Finding {
needs::Finding {
need: String::new(),
level: needs::Level::Fail,
message: message.to_string(),
scope: "repo",
code: "schema",
subject: message.to_string(),
}
}
/// `--findings`: every finding as one JSON line (id, level, code,
/// scope, need, subject, message, fix), the same ones the text output
/// prints - an empty file when there are none, so a reader can tell
/// "clean" from "not run".
fn write_findings(out: Option<&str>, findings: &[needs::Finding]) {
let Some(out) = out else { return };
let lines: String = findings
.iter()
.map(|f| format!("{}\n", f.to_json()))
.collect();
if let Err(e) = std::fs::write(out, lines) {
eprintln!("WARN: could not write the findings to {out}: {e}");
}
}
/// Each finding on its own line, FAIL or WARN first; the count of FAILs.
fn print_findings(findings: &[needs::Finding]) -> usize {
let mut failed = 0;
for finding in findings {
let level = match finding.level {
needs::Level::Fail => {
failed += 1;
"FAIL"
}
needs::Level::Warn => "WARN",
};
eprintln!(
"{level}: {} {:?}: {}",
finding.scope, finding.need, finding.message
);
}
failed
}
/// The business-needs pass (see `crate::lint::needs`): structure always,
/// then the wording tasks written out and/or an engine's answers
/// scored, when asked. A repo without `needs.yaml` skips all of it.
fn check_needs(
findings_out: Option<&str>,
raw: Option<&str>,
questions: &std::collections::HashMap<String, content::Question>,
aggregates_map: &std::collections::HashMap<String, aggregates::AggregateSchema>,
@@ -145,6 +240,7 @@ fn check_needs(
min_accuracy: f64,
) -> anyhow::Result<()> {
let Some(raw) = raw else {
write_findings(findings_out, &[]);
if tasks_out.is_some() || answers_in.is_some() {
eprintln!("FAIL: --needs-tasks/--needs-score given, but the repo has no needs.yaml");
std::process::exit(1);
@@ -155,28 +251,25 @@ fn check_needs(
Ok(file) => file,
Err(e) => {
eprintln!("FAIL: {e}");
write_findings(findings_out, &[schema_finding(&e.to_string())]);
std::process::exit(1);
}
};
let findings = needs::check(&file, questions, aggregates_map);
let mut failed = 0;
for finding in &findings {
let level = match finding.level {
needs::Level::Fail => {
failed += 1;
"FAIL"
}
needs::Level::Warn => "WARN",
};
eprintln!("{level}: need {:?}: {}", finding.need, finding.message);
}
let failed = print_findings(&findings);
write_findings(findings_out, &findings);
for need in &file.needs {
if let Some(path) = needs::path_for(questions, need) {
let steps: Vec<String> = path
.iter()
.map(|s| format!("{} [{}]", s.page, s.alternative))
.collect();
println!("need {:?}: {} -> {:?}", need.id, steps.join(" -> "), need.lands_in);
println!(
"need {:?}: {} -> {:?}",
need.id,
steps.join(" -> "),
need.lands_in
);
}
}
if failed == 0 {
@@ -210,9 +303,14 @@ fn check_needs(
println!("wrote {} wording task(s) to {out}", tasks.len());
}
if let Some(path) = answers_in {
let raw = std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
let raw =
std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
let mut answers = Vec::new();
for (n, line) in raw.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
for (n, line) in raw
.lines()
.enumerate()
.filter(|(_, l)| !l.trim().is_empty())
{
answers.push(
serde_json::from_str::<needs::Answer>(line)
.map_err(|e| anyhow::anyhow!("{path}:{}: {e}", n + 1))?,
+2 -2
View File
@@ -22,7 +22,8 @@ pub fn load_site_from_dir(dir: &str) -> anyhow::Result<content::SiteConfig> {
// The first storyline, held to the arity (content::variants).
let files = vec![("site.yaml".to_string(), raw)];
content::arity(&files)?;
serde_yaml::from_str(&content::storyline(&files, 0)[0].1).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
serde_yaml::from_str(&content::storyline(&files, 0)[0].1)
.map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
}
/// The offline counterpart to `content::load_aggregates_from_gitea` -
@@ -102,4 +103,3 @@ pub fn load_needs_from_dir(dir: &str) -> anyhow::Result<Option<String>> {
.map(Some)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", needs_path.display()))
}
-1
View File
@@ -50,4 +50,3 @@ pub async fn run(mut argv: Vec<String>) -> anyhow::Result<()> {
}
}
}
+755 -55
View File
File diff suppressed because it is too large Load Diff
+104 -20
View File
@@ -14,14 +14,19 @@
use std::collections::{BTreeMap, HashMap};
use futures::StreamExt;
use crate::events::store::{ensure_stream, load_events};
use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir};
use crate::lint::needs;
use crate::{aggregates, answers, content};
use futures::StreamExt;
pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
let value = |flag: &str| {
argv.iter()
.position(|a| a == flag)
.and_then(|i| argv.get(i + 1))
.cloned()
};
let usage = "usage: iris replay --path <questions dir> --nats <url> (--cases <cases.jsonl> | --report-only)";
let dir = value("--path").ok_or_else(|| anyhow::anyhow!(usage))?;
let nats_url = value("--nats").ok_or_else(|| anyhow::anyhow!(usage))?;
@@ -32,31 +37,71 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
let questions = load_from_dir(&dir)?;
let schemas = content::with_builtin_aggregates(load_aggregates_from_dir(&dir)?);
let file = needs::parse(&load_needs_from_dir(&dir)?.ok_or_else(|| anyhow::anyhow!("the repo has no needs.yaml"))?)?;
let file = needs::parse(
&load_needs_from_dir(&dir)?.ok_or_else(|| anyhow::anyhow!("the repo has no needs.yaml"))?,
)?;
let js = async_nats::jetstream::new(async_nats::connect(&nats_url).await?);
// The same call the site makes at boot.
ensure_stream(&js).await?;
if let Some(path) = cases_path {
let raw = std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
let raw =
std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
let (mut created, mut moved, mut refused) = (0, 0, 0);
for (n, line) in raw.lines().filter(|l| !l.trim().is_empty()).enumerate() {
let case: serde_json::Value = serde_json::from_str(line)?;
let Some(need) = file.needs.iter().find(|need| case["need"] == need.id.as_str()) else { continue };
let Some(schema) = schemas.get(&need.lands_in) else { continue };
let Some(need) = file
.needs
.iter()
.find(|need| case["need"] == need.id.as_str())
else {
continue;
};
let Some(schema) = schemas.get(&need.lands_in) else {
continue;
};
// Only a case that got as far as submitting leaves a record.
let states: Vec<&str> = case["states"].as_array().into_iter().flatten().filter_map(|s| s.as_str()).collect();
let states: Vec<&str> = case["states"]
.as_array()
.into_iter()
.flatten()
.filter_map(|s| s.as_str())
.collect();
if states.is_empty() {
continue;
}
let step = needs::path_for(&questions, need).and_then(|p| p.last().cloned());
let (page, alternative) = step.map(|s| (s.page, s.alternative)).unwrap_or_default();
let id = format!("sim-{}-{}-{n}", case["persona"].as_str().unwrap_or("x"), case["run"]);
let id = format!(
"sim-{}-{}-{n}",
case["persona"].as_str().unwrap_or("x"),
case["run"]
);
let now = chrono::Utc::now().timestamp_millis();
answers::store_answer(&js, &schemas, &need.lands_in, id.clone(), &page, &alternative, &case["record"], now, 0).await?;
answers::store_answer(
&js,
&schemas,
&need.lands_in,
id.clone(),
&page,
&alternative,
&case["record"],
now,
0,
)
.await?;
created += 1;
for target in &states[1..] {
match aggregates::transition(&js, schema, &id, target, serde_json::json!({"by": "simulation"}), now).await {
match aggregates::transition(
&js,
schema,
&id,
target,
serde_json::json!({"by": "simulation"}),
now,
)
.await
{
Ok(_) => moved += 1,
Err(e) => {
refused += 1;
@@ -72,33 +117,72 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
// Read every bucket back the way the engine knows it: by replaying
// each record's own event log.
let mut by_bucket: HashMap<String, BTreeMap<String, usize>> = HashMap::new();
for bucket in file.needs.iter().map(|n| n.lands_in.clone()).collect::<std::collections::BTreeSet<_>>() {
let (Some(schema), Ok(store)) = (schemas.get(&bucket), js.get_key_value(&bucket).await) else { continue };
for bucket in file
.needs
.iter()
.map(|n| n.lands_in.clone())
.collect::<std::collections::BTreeSet<_>>()
{
let (Some(schema), Ok(store)) = (schemas.get(&bucket), js.get_key_value(&bucket).await)
else {
continue;
};
let mut keys = store.keys().await?;
while let Some(Ok(id)) = keys.next().await {
let events = load_events(&js, &bucket, &id).await?;
if let Some(record) = aggregates::replay(schema, &id, &events) {
*by_bucket.entry(bucket.clone()).or_default().entry(record.state).or_default() += 1;
*by_bucket
.entry(bucket.clone())
.or_default()
.entry(record.state)
.or_default() += 1;
}
}
}
println!("{:<18} {:>8} {:>9} {:>9} {}", "bucket", "records", "finished", "success", "by state");
println!(
"{:<18} {:>8} {:>9} {:>9} {}",
"bucket", "records", "finished", "success", "by state"
);
let mut buckets: Vec<_> = by_bucket.iter().collect();
buckets.sort();
for (bucket, states) in buckets {
let needs_here: Vec<&needs::Need> = file.needs.iter().filter(|n| &n.lands_in == bucket).collect();
let is = |pick: fn(&needs::Need) -> &Vec<String>, state: &String| needs_here.iter().any(|n| pick(n).contains(state));
let needs_here: Vec<&needs::Need> = file
.needs
.iter()
.filter(|n| &n.lands_in == bucket)
.collect();
let is = |pick: fn(&needs::Need) -> &Vec<String>, state: &String| {
needs_here.iter().any(|n| pick(n).contains(state))
};
let total: usize = states.values().sum();
let finished: usize = states.iter().filter(|(s, _)| is(|n| &n.done_when, s)).map(|(_, c)| c).sum();
let success: usize = states.iter().filter(|(s, _)| is(|n| &n.success, s)).map(|(_, c)| c).sum();
let pct = |n: usize| if total == 0 { "-".to_string() } else { format!("{:.0}%", n as f64 * 100.0 / total as f64) };
let finished: usize = states
.iter()
.filter(|(s, _)| is(|n| &n.done_when, s))
.map(|(_, c)| c)
.sum();
let success: usize = states
.iter()
.filter(|(s, _)| is(|n| &n.success, s))
.map(|(_, c)| c)
.sum();
let pct = |n: usize| {
if total == 0 {
"-".to_string()
} else {
format!("{:.0}%", n as f64 * 100.0 / total as f64)
}
};
println!(
"{:<18} {:>8} {:>9} {:>9} {}",
bucket,
total,
pct(finished),
pct(success),
states.iter().map(|(s, c)| format!("{s} {c}")).collect::<Vec<_>>().join(", ")
states
.iter()
.map(|(s, c)| format!("{s} {c}"))
.collect::<Vec<_>>()
.join(", ")
);
}
Ok(())
+491 -78
View File
@@ -74,7 +74,10 @@ pub struct Incoming {
/// 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> {
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));
}
@@ -90,7 +93,10 @@ pub fn note_for(incoming: &Incoming, answer: &crate::answers::Answer) -> Result<
.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));
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());
@@ -128,7 +134,10 @@ pub async fn receive(state: crate::server::AppState) {
return;
}
};
tracing::info!(subject = RECEIVED_SUBJECT, "listening for replies to case mail");
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,
@@ -138,8 +147,12 @@ pub async fn receive(state: crate::server::AppState) {
}
};
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}"),
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}")
}
}
}
}
@@ -147,7 +160,9 @@ pub async fn receive(state: crate::server::AppState) {
/// 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) {
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?;
@@ -158,14 +173,18 @@ async fn note_onto(state: &crate::server::AppState, incoming: &Incoming) -> anyh
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?;
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> {
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(),
@@ -213,7 +232,8 @@ pub fn render(template: &str, vars: &serde_json::Map<String, serde_json::Value>)
/// 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 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!(
@@ -221,28 +241,46 @@ pub fn to_html(markdown: &str) -> String {
)
}
fn public_url() -> String {
std::env::var("PUBLIC_URL").unwrap_or_default().trim_end_matches('/').to_string()
pub(crate) 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())
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> {
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()),
});
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();
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());
@@ -251,6 +289,85 @@ pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Val
vars
}
/// What every mail portal sends may say, whatever made it - a state
/// mail, an invite, a suggestion's answer: `{site}` (the instance's
/// public URL) and `{site_name}` (site.yaml's title). One place, so a
/// template written for one kind of mail reads the same in another
/// (docs/mail.md, Variables).
pub fn site_vars(site: &crate::content::SiteConfig) -> serde_json::Map<String, serde_json::Value> {
let mut vars = serde_json::Map::new();
vars.insert("site".into(), public_url().into());
vars.insert("site_name".into(), site_name(site).into());
vars
}
/// Where a mail about a record can send its reader (docs/mail.md):
///
/// - `{self_link}`: the record's own page for the person it belongs
/// to - a page offering a self-service move on this bucket from the
/// state the record is in, with the `?chain=&email=` that
/// lets them act without signing in;
/// - `{desk_link}`: the desk that moves the record - the page whose
/// list over the bucket carries transitions, else one that reads it;
/// - `{link}`: `{self_link}` in a mail to the submitter, `{desk_link}`
/// in one to anyone else, and the site itself when there is neither.
///
/// Empty strings, never missing keys, for a link the content has no
/// page for: a template naming one renders blank there rather than
/// with a broken URL.
pub fn record_links(
questions: &std::collections::HashMap<String, crate::content::Question>,
initial: &str,
bucket: &str,
id: &str,
state: &str,
email: &str,
to_submitter: bool,
) -> serde_json::Map<String, serde_json::Value> {
let mut pages: Vec<&crate::content::Question> = questions.values().collect();
pages.sort_by(|a, b| a.id.cmp(&b.id));
// Only a move the engine would accept from where the record is:
// a link to a button that refuses is worse than none.
let self_page = pages.iter().find(|q| {
q.alternatives
.iter()
.filter_map(|a| a.self_transition.as_ref())
.any(|st| st.bucket == bucket && st.from_states(initial).iter().any(|f| f == state))
});
let reads = |q: &&crate::content::Question, moving: bool| {
q.alternatives
.iter()
.flat_map(|a| &a.features)
.filter_map(|f| f.resource.as_ref())
.any(|r| r.bucket() == Some(bucket) && (!moving || !r.transitions.is_empty()))
};
let desk_page = pages
.iter()
.find(|q| reads(q, true))
.or_else(|| pages.iter().find(|q| reads(q, false)));
let site = public_url();
let self_link = match self_page {
Some(q) if email.contains('@') => {
let email: String = url::form_urlencoded::byte_serialize(email.as_bytes()).collect();
format!("{site}{}?chain={id}&email={email}", q.id)
}
_ => String::new(),
};
let desk_link = desk_page
.map(|q| format!("{site}{}", q.id))
.unwrap_or_default();
let link = match (to_submitter, self_link.is_empty(), desk_link.is_empty()) {
(true, false, _) => self_link.clone(),
(false, _, false) => desk_link.clone(),
_ => site,
};
let mut vars = serde_json::Map::new();
vars.insert("self_link".into(), self_link.into());
vars.insert("desk_link".into(), desk_link.into());
vars.insert("link".into(), link.into());
vars
}
/// The record field a `to` reaches through, when it is neither
/// `submitter` nor an address: `{notice.email}` gives `notice`.
pub fn parent_of(to: &str) -> Option<&str> {
@@ -264,7 +381,12 @@ pub fn parent_of(to: &str) -> Option<&str> {
/// `bucket`, the record it names, read from its own bucket. A
/// reference that does not resolve adds nothing, and a template that
/// needed it renders empty there.
async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &serde_json::Value, vars: &mut serde_json::Map<String, serde_json::Value>) {
async fn parent_vars(
state: &crate::server::AppState,
bucket: &str,
responses: &serde_json::Value,
vars: &mut serde_json::Map<String, serde_json::Value>,
) {
let questions = state.questions.load();
let refs: Vec<(String, String)> = questions
.values()
@@ -275,10 +397,22 @@ async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &
.filter_map(|r| Some((r.name.clone(), r.of.clone()?)))
.collect();
for (field, of) in refs {
let Some(id) = responses.get(&field).and_then(|v| v.as_str()).filter(|s| !s.is_empty()) else { continue };
let Ok(store) = state.jetstream.get_key_value(&of).await else { continue };
let Ok(Some(bytes)) = store.get(id).await else { continue };
let Ok(parent) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
let Some(id) = responses
.get(&field)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
else {
continue;
};
let Ok(store) = state.jetstream.get_key_value(&of).await else {
continue;
};
let Ok(Some(bytes)) = store.get(id).await else {
continue;
};
let Ok(parent) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else {
continue;
};
if let Some(fields) = parent.responses.as_object() {
for (k, v) in fields {
let text = match v {
@@ -298,7 +432,9 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow
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?;
js.publish_with_headers(SUBJECT, headers, bytes.into())
.await?
.await?;
Ok(())
}
@@ -306,10 +442,24 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow
/// 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, storyline: u32) {
pub async fn on_state(
state: &crate::server::AppState,
bucket: &str,
id: &str,
to_state: &str,
responses: &serde_json::Value,
storyline: u32,
) {
// The mail tells the storyline the person read.
let storyline = storyline as usize;
let Some(spec) = state.aggregates.load().at(storyline).get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
let Some(spec) = state
.aggregates
.load()
.at(storyline)
.get(bucket)
.and_then(|s| s.mail_for(to_state))
.cloned()
else {
// A suggestion has its own words for this unless the content
// gave the state a mail, which then is the one sent.
if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET {
@@ -320,16 +470,54 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
let site_storylines = state.site.load();
let site = site_storylines.at(storyline);
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");
tracing::warn!(
bucket,
to_state,
"a mail is declared for this state, but site.yaml has no `mail.from` - not sent"
);
return;
};
let mut vars = vars_for(bucket, id, to_state, responses);
parent_vars(state, bucket, responses, &mut vars).await;
vars.extend(site_vars(site));
{
let email = vars
.get("email")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let questions = state.questions.load();
let initial = state
.aggregates
.load()
.at(storyline)
.get(bucket)
.map(|s| s.initial.clone())
.unwrap_or_default();
vars.extend(record_links(
questions.at(storyline),
&initial,
bucket,
id,
to_state,
&email,
spec.to_submitter(),
));
}
let to = if spec.to_submitter() {
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
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");
tracing::warn!(
bucket,
id,
to_state,
"mail to the submitter, but the record carries no email - not sent"
);
return;
}
}
@@ -363,14 +551,24 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
/// quotes, and past the first three only how many more - someone who
/// answers for a whole site is not read its table of contents.
pub fn questions_listed(questions: &[String], norwegian: bool) -> String {
let quote = |q: &String| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") };
let quote = |q: &String| {
if norwegian {
format!("«{q}»")
} else {
format!("\u{201c}{q}\u{201d}")
}
};
let and = if norwegian { "og" } else { "and" };
match questions {
[] => String::new(),
[one] => quote(one),
few if few.len() <= 3 => {
let quoted: Vec<String> = few.iter().map(quote).collect();
format!("{} {and} {}", quoted[..quoted.len() - 1].join(", "), quoted[quoted.len() - 1])
format!(
"{} {and} {}",
quoted[..quoted.len() - 1].join(", "),
quoted[quoted.len() - 1]
)
}
many => {
let quoted: Vec<String> = many[..3].iter().map(quote).collect();
@@ -390,13 +588,20 @@ pub fn questions_listed(questions: &[String], norwegian: bool) -> String {
/// a question and they had no account: who they are to the site (the
/// person listed as asking that question), and the link that lets them
/// sign in and suggest changes to it. Built in, in the site's language.
pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::people::Invited, questions: &[String]) {
pub async fn on_responsible(
state: &crate::server::AppState,
invited: &crate::people::Invited,
questions: &[String],
) {
let site = state.site.load();
let Some(cfg) = &site.mail else {
tracing::warn!(user = %invited.username, "an account was made for a responsible person, but site.yaml has no `mail.from` - they were not told");
return;
};
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let norwegian = site
.lang
.as_deref()
.is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let list = questions_listed(questions, norwegian);
let (subject, body) = if norwegian {
(
@@ -419,7 +624,9 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
// The form's own fields first, so the invite's fixed placeholders
// win a clash; a date and time reads as one.
for (key, value) in &invited.fields {
let text = value.as_str().map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string()));
let text = value
.as_str()
.map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string()));
if let Some(text) = text {
vars.insert(key.clone(), text.into());
}
@@ -427,9 +634,11 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
vars.insert("name".into(), invited.display_name.clone().into());
vars.insert("username".into(), invited.username.clone().into());
vars.insert("questions".into(), list.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.extend(site_vars(&site));
vars.insert(
"link".into(),
invited.reset_link.clone().unwrap_or_default().into(),
);
vars.insert("expires".into(), expires.into());
let text = render(&body, &vars);
let mail = Outgoing {
@@ -484,32 +693,65 @@ pub fn suggestion_words(to_state: &str, norwegian: bool) -> Option<(&'static str
/// A suggestion moved: tell whoever made it. Replies go to the site's
/// inbox, which is who decided.
async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state: &str, responses: &serde_json::Value) {
async fn on_suggestion_state(
state: &crate::server::AppState,
id: &str,
to_state: &str,
responses: &serde_json::Value,
) {
let site = state.site.load();
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let norwegian = site
.lang
.as_deref()
.is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let Some((subject, body)) = suggestion_words(to_state, norwegian) else {
return;
};
let Some(cfg) = &site.mail else {
tracing::warn!(id, to_state, "a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told");
tracing::warn!(
id,
to_state,
"a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told"
);
return;
};
let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
let field = |name: &str| {
responses
.get(name)
.and_then(|v| v.as_str())
.unwrap_or_default()
.trim()
.to_string()
};
let to = field("email");
if !to.contains('@') {
tracing::warn!(id, to_state, "a suggestion moved, but the record carries no email - not sent");
tracing::warn!(
id,
to_state,
"a suggestion moved, but the record carries no email - not sent"
);
return;
}
let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::<Vec<_>>().join("\n");
let mut vars = serde_json::Map::new();
let quoted: String = field("text")
.lines()
.map(|l| format!("> {l}"))
.collect::<Vec<_>>()
.join("\n");
let mut vars = site_vars(&site);
vars.insert("name".into(), field("name").into());
vars.insert("question".into(), field("question").into());
vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into());
vars.insert(
"page".into(),
format!("{}{}", public_url(), field("page")).into(),
);
vars.insert("quoted".into(), quoted.into());
let text = render(body, &vars);
let mail = Outgoing {
v: CONTRACT,
id: format!("{}:{id}:{to_state}", crate::content::EDIT_SUGGESTIONS_BUCKET),
id: format!(
"{}:{id}:{to_state}",
crate::content::EDIT_SUGGESTIONS_BUCKET
),
site: site_name(&site),
from: cfg.from.clone(),
reply_to: cfg.reply_to.clone(),
@@ -526,14 +768,24 @@ async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state
/// A change suggested by the person responsible for a page, to the
/// site's own inbox (`mail.reply_to`, else `mail.from`), with the
/// suggester as reply-to so answering the mail answers them.
pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &str, question_name: &str, by_name: &str, by_email: &str, text: &str) {
pub async fn on_edit_suggestion(
state: &crate::server::AppState,
question_id: &str,
question_name: &str,
by_name: &str,
by_email: &str,
text: &str,
) {
let site = state.site.load();
let Some(cfg) = &site.mail else {
tracing::warn!(question_id, "an edit was suggested, but site.yaml has no `mail.from` - it is in portal_edit_suggestions only");
return;
};
let to = cfg.reply_to.clone().unwrap_or_else(|| cfg.from.clone());
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let norwegian = site
.lang
.as_deref()
.is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let (subject, lead) = if norwegian {
(format!("Forslag til endring: «{question_name}»"), format!("{by_name} ({by_email}), som står som ansvarlig for spørsmålet, foreslår en endring på {}{question_id}:", public_url()))
} else {
@@ -570,13 +822,63 @@ pub fn spoken_datetime(value: &str, norwegian: bool) -> Option<String> {
let day = t.weekday().num_days_from_monday() as usize;
let month = t.month0() as usize;
Some(if norwegian {
const DAYS: [&str; 7] = ["mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag"];
const MONTHS: [&str; 12] = ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"];
format!("{} {}. {} kl. {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
const DAYS: [&str; 7] = [
"mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag",
];
const MONTHS: [&str; 12] = [
"januar",
"februar",
"mars",
"april",
"mai",
"juni",
"juli",
"august",
"september",
"oktober",
"november",
"desember",
];
format!(
"{} {}. {} kl. {:02}:{:02}",
DAYS[day],
t.day(),
MONTHS[month],
t.hour(),
t.minute()
)
} else {
const DAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
format!("{} {} {} at {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
const DAYS: [&str; 7] = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];
const MONTHS: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
format!(
"{} {} {} at {:02}:{:02}",
DAYS[day],
t.day(),
MONTHS[month],
t.hour(),
t.minute()
)
})
}
@@ -586,7 +888,10 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
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 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 (invited.mail.as_ref().or(cfg.invite.as_ref()), invited.reset_link.is_some(), norwegian) {
(Some(t), _, _) => (t.subject.clone(), t.body.clone()),
(None, true, true) => (
@@ -617,9 +922,11 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
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.extend(site_vars(&site));
vars.insert(
"link".into(),
invited.reset_link.clone().unwrap_or_default().into(),
);
vars.insert("expires".into(), expires.into());
let text = render(&body, &vars);
let mail = Outgoing {
@@ -640,6 +947,57 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
#[cfg(test)]
mod tests {
#[test]
fn a_record_mail_links_its_reader_to_where_they_act() {
let files = vec![
("index.yaml".to_string(), "name: Meld fra\nalternatives:\n - name: Feil\n action: /takk\n record_as: feil\n features:\n - name: Feil\n requirements:\n - { name: email, type: email }\n".to_string()),
("takk.yaml".to_string(), "name: Takk\nfollowup: true\n".to_string()),
("trekk.yaml".to_string(), "name: Trekk saken\nalternatives:\n - name: Trekk\n self_transition: { bucket: feil, to: trukket, label: Trekk, from: [open, befaring] }\n".to_string()),
("styret/index.yaml".to_string(), "name: Styret\nqualifies: styret\nalternatives:\n - name: Feil\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: feil }\n requires_group: styret\n transitions:\n - { to: befaring, label: Befaring }\n".to_string()),
];
let questions = crate::content::build_questions(&files).unwrap();
let site = public_url();
let to_tenant = record_links(
&questions,
"open",
"feil",
"abc",
"befaring",
"kari@x.no",
true,
);
assert_eq!(
to_tenant["self_link"],
format!("{site}/trekk?chain=abc&email=kari%40x.no")
);
assert_eq!(to_tenant["desk_link"], format!("{site}/styret"));
assert_eq!(to_tenant["link"], to_tenant["self_link"]);
let to_board = record_links(
&questions,
"open",
"feil",
"abc",
"befaring",
"kari@x.no",
false,
);
assert_eq!(to_board["link"], to_board["desk_link"]);
// Past where the self-service move can be made: the person has
// no page of their own, so `{link}` is the site.
let after = record_links(
&questions,
"open",
"feil",
"abc",
"utbedret",
"kari@x.no",
true,
);
assert_eq!(after["self_link"], "");
assert_eq!(after["link"], site);
}
use super::*;
#[test]
@@ -648,10 +1006,22 @@ mod tests {
assert_eq!(questions_listed(&q(0), true), "");
assert_eq!(questions_listed(&q(1), true), "«Q1?»");
assert_eq!(questions_listed(&q(3), true), "«Q1?», «Q2?» og «Q3?»");
assert_eq!(questions_listed(&q(4), true), "«Q1?», «Q2?», «Q3?» og ett spørsmål til");
assert_eq!(questions_listed(&q(17), true), "«Q1?», «Q2?», «Q3?» og 14 spørsmål til");
assert_eq!(questions_listed(&q(2), false), "\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}");
assert_eq!(questions_listed(&q(5), false), "\u{201c}Q1?\u{201d}, \u{201c}Q2?\u{201d}, \u{201c}Q3?\u{201d} and 2 more questions");
assert_eq!(
questions_listed(&q(4), true),
"«Q1?», «Q2?», «Q3?» og ett spørsmål til"
);
assert_eq!(
questions_listed(&q(17), true),
"«Q1?», «Q2?», «Q3?» og 14 spørsmål til"
);
assert_eq!(
questions_listed(&q(2), false),
"\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}"
);
assert_eq!(
questions_listed(&q(5), false),
"\u{201c}Q1?\u{201d}, \u{201c}Q2?\u{201d}, \u{201c}Q3?\u{201d} and 2 more questions"
);
}
#[test]
@@ -659,8 +1029,14 @@ mod tests {
for norwegian in [true, false] {
for state in ["discussed", "done", "declined"] {
let (subject, body) = suggestion_words(state, norwegian).expect(state);
assert!(subject.contains("{question}"), "{state}: the subject names the question");
assert!(body.contains("{page}") && body.contains("{quoted}"), "{state}: the body says where and what");
assert!(
subject.contains("{question}"),
"{state}: the subject names the question"
);
assert!(
body.contains("{page}") && body.contains("{quoted}"),
"{state}: the body says where and what"
);
}
// Arriving is told to the inbox, not back to who sent it.
assert!(suggestion_words("open", norwegian).is_none());
@@ -681,18 +1057,34 @@ mod tests {
#[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 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!(
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 a_date_and_time_from_a_form_reads_as_a_person_says_it() {
assert_eq!(spoken_datetime("2026-10-06T15:00", true).as_deref(), Some("tirsdag 6. oktober kl. 15:00"));
assert_eq!(spoken_datetime("2026-10-06T15:00", false).as_deref(), Some("Tuesday 6 October at 15:00"));
assert_eq!(
spoken_datetime("2026-10-06T15:00", true).as_deref(),
Some("tirsdag 6. oktober kl. 15:00")
);
assert_eq!(
spoken_datetime("2026-10-06T15:00", false).as_deref(),
Some("Tuesday 6 October at 15:00")
);
assert_eq!(spoken_datetime("Anders Kluge", true), None);
}
@@ -728,7 +1120,9 @@ mod tests {
)
.unwrap();
assert_eq!(older.subject, "s");
assert!(serde_json::to_string(&older).unwrap().contains("\"html\":\"h\""));
assert!(serde_json::to_string(&older)
.unwrap()
.contains("\"html\":\"h\""));
}
fn record(email: &str) -> crate::answers::Answer {
@@ -743,6 +1137,7 @@ mod tests {
decided_ms: None,
decided_by: None,
notes: Vec::new(),
finished: false,
}
}
@@ -761,7 +1156,11 @@ mod tests {
#[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();
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");
@@ -772,21 +1171,33 @@ mod tests {
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}");
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"));
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"));
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());
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}");
}
@@ -795,7 +1206,9 @@ mod tests {
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"));
assert!(note_for(&future, &record("kari@x.no"))
.unwrap_err()
.contains("contract"));
}
#[test]
+54 -22
View File
@@ -1,5 +1,6 @@
/// One binary, three jobs: `portal` (or `portal serve`) runs a site;
/// `portal lint` checks a content repo the way the site reads it;
/// `portal iris` checks a content repo the way the site reads it (the
/// iris keeps bad content out of the portal; `portal lint` is the same);
/// `portal plan`, `build`, `report` and the rest train one. The lint
/// and the trainer are the same code as the server, at the same
/// version, so they never disagree about what content means.
@@ -8,7 +9,7 @@
async fn main() -> anyhow::Result<()> {
let argv: Vec<String> = std::env::args().skip(1).collect();
match argv.first().map(String::as_str) {
Some("lint") => return portal::lint::run(argv[1..].to_vec()).await,
Some("iris" | "lint") => return portal::lint::run(argv[1..].to_vec()).await,
Some("plan" | "build" | "answer" | "rate" | "prefer" | "score" | "simulate" | "compare" | "report" | "generate") => {
// The trainer runs models through shell commands and waits
// on them; off the runtime's threads, as a CLI should.
@@ -19,14 +20,21 @@ async fn main() -> anyhow::Result<()> {
return Ok(());
}
Some("serve") | None => {}
Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, lint, plan, build, answer, rate, prefer, score, simulate, compare, report, generate"),
Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, iris (or lint), plan, build, answer, rate, prefer, score, simulate, compare, report, generate"),
}
serve().await
}
#[cfg(feature = "ssr")]
async fn serve() -> anyhow::Result<()> {
use axum::{body::Body, extract::State, http::Request, response::IntoResponse, routing::{any, get, post}, Router};
use axum::{
body::Body,
extract::State,
http::Request,
response::IntoResponse,
routing::{any, get, post},
Router,
};
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use portal::app::{shell, App};
@@ -56,12 +64,15 @@ async fn serve() -> anyhow::Result<()> {
let gitea_base = content::gitea_api_base(&content_repo)?;
let content_raw_base = content::gitea_raw_base(&content_repo)?;
let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?;
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
let questions =
content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
content::validate_questions(&questions, &aggregates)?;
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
let storylines = content::same_arity(questions.arity(), aggregates.arity(), site.arity())?;
tracing::info!(count = questions.len(), storylines, 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 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));
@@ -127,7 +138,12 @@ async fn serve() -> anyhow::Result<()> {
// The same decisions, answerable by other apps over NATS, for a
// person named by phone number or address as well as by groups.
tokio::spawn(portal::access::serve(state.nats.clone(), site_name_for_subjects(), state.access.clone(), Some(state.clone())));
tokio::spawn(portal::access::serve(
state.nats.clone(),
site_name_for_subjects(),
state.access.clone(),
Some(state.clone()),
));
// Announced pages: keep their desk records current, close ended
// windows (see announce.rs).
tokio::spawn(portal::announce::run(state.clone()));
@@ -201,18 +217,17 @@ async fn serve() -> anyhow::Result<()> {
});
match &governor {
Some(_) => tracing::info!(rate_per_second, rate_burst, "rate limiting per caller IP"),
None => tracing::warn!("rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"),
None => tracing::warn!(
"rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"
),
}
async fn server_fn_handler(
State(state): State<AppState>,
request: Request<Body>,
) -> impl IntoResponse {
leptos_axum::handle_server_fns_with_context(
move || provide_context(state.clone()),
request,
)
.await
leptos_axum::handle_server_fns_with_context(move || provide_context(state.clone()), request)
.await
}
// The app's own Leptos route list includes a root-level wildcard
@@ -263,10 +278,7 @@ async fn serve() -> anyhow::Result<()> {
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
.route_service("/wordmark.svg", ServeFile::new(wordmark_path))
.route_service(
"/swiper-element-bundle.min.js",
ServeFile::new(swiper_path),
)
.route_service("/swiper-element-bundle.min.js", ServeFile::new(swiper_path))
// Same name every release, so revalidated like /pkg.
.route_service(
"/page-motion.js",
@@ -290,7 +302,9 @@ async fn serve() -> anyhow::Result<()> {
// with room for the multipart framing.
.route(
"/upload",
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(
upload::MAX_UPLOAD_BYTES + 64 * 1024,
)),
)
.route(
"/attachment/{bucket}/{record}/{field}",
@@ -301,7 +315,12 @@ async fn serve() -> anyhow::Result<()> {
.route("/report", get(portal::report::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))
.route("/.well-known/{*path}", get(content::well_known_handler))
.route("/profile.mobileconfig", get(portal::profile::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).
@@ -323,7 +342,10 @@ async fn serve() -> anyhow::Result<()> {
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
.layer(session_layer)
// Which storyline this visitor reads, before anything renders.
.layer(axum::middleware::from_fn_with_state(state.clone(), portal::server::storyline::assign));
.layer(axum::middleware::from_fn_with_state(
state.clone(),
portal::server::storyline::assign,
));
// 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 {
@@ -361,7 +383,17 @@ 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 { '_' })
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
if folded.is_empty() { "portal".to_string() } else { folded }
if folded.is_empty() {
"portal".to_string()
} else {
folded
}
}
+76 -15
View File
@@ -42,7 +42,11 @@ fn style_id() -> String {
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 == '_'))
[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())
}
@@ -50,7 +54,9 @@ fn style_id() -> 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())
std::env::var("MAPBOX_TOKEN")
.ok()
.filter(|t| !t.trim().is_empty())
}
#[cfg(feature = "ssr")]
@@ -64,7 +70,11 @@ pub fn enabled() -> bool {
#[cfg(feature = "ssr")]
pub async fn style() -> Response {
if !enabled() {
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
return (
StatusCode::NOT_FOUND,
"maps are not configured on this instance",
)
.into_response();
}
let style = serde_json::json!({
"version": 8,
@@ -79,7 +89,10 @@ pub async fn style() -> Response {
"layers": [{ "id": "base", "type": "raster", "source": "base" }],
});
(
[(header::CONTENT_TYPE, "application/json"), (header::CACHE_CONTROL, "public, max-age=3600")],
[
(header::CONTENT_TYPE, "application/json"),
(header::CACHE_CONTROL, "public, max-age=3600"),
],
style.to_string(),
)
.into_response()
@@ -90,7 +103,11 @@ pub async fn style() -> Response {
#[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();
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();
@@ -108,13 +125,21 @@ pub async fn tile(Path((z, x, y)): Path<(u32, u32, u32)>) -> Response {
#[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();
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();
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.
@@ -127,7 +152,12 @@ pub async fn still(Path((place, size)): Path<(String, String)>) -> Response {
#[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()) {
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}");
@@ -143,7 +173,10 @@ async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response {
match crate::http::bytes(resp, crate::http::ASSET_CAP).await {
Ok(bytes) => (
[
(header::CONTENT_TYPE, upstream.unwrap_or_else(|| mime.to_string())),
(
header::CONTENT_TYPE,
upstream.unwrap_or_else(|| mime.to_string()),
),
(header::CACHE_CONTROL, cache.to_string()),
],
axum::body::Bytes::from(bytes),
@@ -159,16 +192,25 @@ async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response {
#[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 {
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) {
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}")))
Some((
format!("{lon:.5}"),
format!("{lat:.5}"),
format!("{zoom:.2}"),
))
}
#[cfg(feature = "ssr")]
@@ -203,7 +245,15 @@ mod tests {
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"] {
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}");
}
}
@@ -212,7 +262,15 @@ mod tests {
#[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"] {
for bad in [
"600",
"0x300",
"600x0",
"2000x300",
"600x2000",
"600x300x2",
"sixx300",
] {
assert_eq!(parse_size(bad), None, "{bad}");
}
}
@@ -221,7 +279,10 @@ mod tests {
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)));
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 }),
+219 -45
View File
@@ -32,7 +32,10 @@ pub const BY: &str = "portal";
/// The country a phone number without one is in, as a calling code.
fn default_country() -> String {
std::env::var("PHONE_COUNTRY").ok().filter(|c| !c.trim().is_empty()).unwrap_or_else(|| "+47".to_string())
std::env::var("PHONE_COUNTRY")
.ok()
.filter(|c| !c.trim().is_empty())
.unwrap_or_else(|| "+47".to_string())
}
/// Who a person is, as far as a record or a session knows.
@@ -69,7 +72,11 @@ pub fn normalize_phone(raw: &str, country: &str) -> Option<String> {
}
fn normalize_email(raw: &str) -> Option<String> {
let e = raw.trim().trim_start_matches("mailto:").trim().to_lowercase();
let e = raw
.trim()
.trim_start_matches("mailto:")
.trim()
.to_lowercase();
(e.contains('@') && !e.contains(char::is_whitespace)).then_some(e)
}
@@ -88,7 +95,8 @@ impl Identity {
let country = default_country();
Identity {
sub: None,
phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"]).and_then(|p| normalize_phone(&p, &country)),
phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"])
.and_then(|p| normalize_phone(&p, &country)),
email: field(&["email", "epost", "e-post", "mail"]).and_then(|e| normalize_email(&e)),
}
}
@@ -97,7 +105,10 @@ impl Identity {
let country = default_country();
Identity {
sub: Some(user.sub.clone()),
phone: user.phone.as_deref().and_then(|p| normalize_phone(p, &country)),
phone: user
.phone
.as_deref()
.and_then(|p| normalize_phone(p, &country)),
email: user.email.as_deref().and_then(normalize_email),
}
}
@@ -107,8 +118,16 @@ impl Identity {
pub fn from_contact(contact: &str) -> Self {
let country = default_country();
match normalize_email(contact) {
Some(email) => Identity { sub: None, phone: None, email: Some(email) },
None => Identity { sub: None, phone: normalize_phone(contact, &country), email: None },
Some(email) => Identity {
sub: None,
phone: None,
email: Some(email),
},
None => Identity {
sub: None,
phone: normalize_phone(contact, &country),
email: None,
},
}
}
@@ -120,14 +139,26 @@ impl Identity {
.as_deref()
.or(self.email.as_deref())
.or(self.sub.as_deref())
.map(|k| k.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect())
.map(|k| {
k.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
})
}
/// Whether a record about `other` is about this person: the same
/// subject, phone number or address, any one of them.
pub fn matches(&self, other: &Identity) -> bool {
let same = |a: &Option<String>, b: &Option<String>| a.is_some() && a == b;
same(&self.sub, &other.sub) || same(&self.phone, &other.phone) || same(&self.email, &other.email)
same(&self.sub, &other.sub)
|| same(&self.phone, &other.phone)
|| same(&self.email, &other.email)
}
/// What is worth writing on a record from a login: everything the
@@ -149,14 +180,34 @@ pub fn record_id(group: &str, key: &str) -> String {
/// The identity a record holds.
pub fn identity_of(answer: &Answer) -> Identity {
Identity {
sub: answer.responses.get("sub").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
phone: answer.responses.get("phone").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
email: answer.responses.get("email").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
sub: answer
.responses
.get("sub")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
phone: answer
.responses
.get("phone")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
email: answer
.responses
.get("email")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
}
}
pub fn group_of(answer: &Answer) -> String {
answer.responses.get("group").and_then(|v| v.as_str()).unwrap_or_default().to_string()
answer
.responses
.get("group")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
}
/// The groups `who` is an active member of, from every record.
@@ -172,19 +223,26 @@ pub fn groups_of(records: &[Answer], who: &Identity) -> Vec<String> {
groups
}
async fn store(state: &crate::server::AppState) -> anyhow::Result<async_nats::jetstream::kv::Store> {
async fn store(
state: &crate::server::AppState,
) -> anyhow::Result<async_nats::jetstream::kv::Store> {
match state.jetstream.get_key_value(BUCKET).await {
Ok(store) => Ok(store),
Err(_) => Ok(state
.jetstream
.create_key_value(async_nats::jetstream::kv::Config { bucket: BUCKET.to_string(), ..Default::default() })
.create_key_value(async_nats::jetstream::kv::Config {
bucket: BUCKET.to_string(),
..Default::default()
})
.await?),
}
}
/// Every membership record.
pub async fn all(state: &crate::server::AppState) -> Vec<Answer> {
let Ok(store) = state.jetstream.get_key_value(BUCKET).await else { return Vec::new() };
let Ok(store) = state.jetstream.get_key_value(BUCKET).await else {
return Vec::new();
};
let keys: Vec<String> = {
use futures::TryStreamExt;
match store.keys().await {
@@ -205,18 +263,36 @@ pub async fn all(state: &crate::server::AppState) -> Vec<Answer> {
/// Moves one record the way a desk button would: through the event
/// log, onto the projection, out on the answers subject.
async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: Answer, target: &str, by: &str) -> anyhow::Result<Answer> {
async fn move_to(
state: &crate::server::AppState,
store: &async_nats::jetstream::kv::Store,
mut answer: Answer,
target: &str,
by: &str,
) -> anyhow::Result<Answer> {
let aggregates = state.aggregates.load();
let schema = aggregates.get(BUCKET).ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?;
let schema = aggregates
.get(BUCKET)
.ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?;
let now_ms = chrono::Utc::now().timestamp_millis();
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": by });
crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
crate::answers::transition_or_reseed(
&state.jetstream,
schema,
&answer.id,
target,
&answer.state,
payload.clone(),
now_ms,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
answer.state = target.to_string();
answer.decided_ms = Some(now_ms);
answer.decided_by = Some(by.to_string());
store.put(&answer.id, serde_json::to_vec(&answer)?.into()).await?;
store
.put(&answer.id, serde_json::to_vec(&answer)?.into())
.await?;
let parent_hashes = vec![answer.id.clone()];
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
let event = crate::events::AnswerSubmitted {
@@ -236,15 +312,29 @@ async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream:
/// `invited`; a removed one is invited again; an invited or active one
/// is left as it is, so a second grant costs nothing. `created` in
/// what comes back says whether the person had a place already.
pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who: &Identity, by: &str) -> anyhow::Result<crate::people::Invited> {
let key = who.key().ok_or_else(|| anyhow::anyhow!("a membership needs a phone number or an address to know the person by"))?;
pub async fn grant(
state: &crate::server::AppState,
group: &str,
name: &str,
who: &Identity,
by: &str,
) -> anyhow::Result<crate::people::Invited> {
let key = who.key().ok_or_else(|| {
anyhow::anyhow!("a membership needs a phone number or an address to know the person by")
})?;
let store = store(state).await?;
// The person's record in this group, however it was keyed: a desk
// that invited by address and a decision that granted by phone
// number are talking about one person once a sign-in has joined
// the two, and must not make two.
let existing: Option<Answer> = all(state).await.into_iter().find(|a| group_of(a) == group.trim() && identity_of(a).matches(who));
let id = existing.as_ref().map(|a| a.id.clone()).unwrap_or_else(|| record_id(group, &key));
let existing: Option<Answer> = all(state)
.await
.into_iter()
.find(|a| group_of(a) == group.trim() && identity_of(a).matches(who));
let id = existing
.as_ref()
.map(|a| a.id.clone())
.unwrap_or_else(|| record_id(group, &key));
let now_ms = chrono::Utc::now().timestamp_millis();
let invited = |created: bool| crate::people::Invited {
username: key.clone(),
@@ -269,7 +359,18 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
"name": name, "group": group, "by": by,
"phone": who.phone, "email": who.email, "sub": who.sub,
});
crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), BUCKET, id, "/", "Membership", &responses, now_ms, 0).await?;
crate::answers::store_answer(
&state.jetstream,
&state.aggregates.load(),
BUCKET,
id,
"/",
"Membership",
&responses,
now_ms,
0,
)
.await?;
Ok(invited(true))
}
}
@@ -277,11 +378,22 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
/// Takes a person out of a group: the record moves to `removed` and
/// stays, so the desk can see who was in and put them back.
pub async fn revoke(state: &crate::server::AppState, group: &str, who: &Identity, by: &str) -> anyhow::Result<bool> {
let Some(key) = who.key() else { return Ok(false) };
pub async fn revoke(
state: &crate::server::AppState,
group: &str,
who: &Identity,
by: &str,
) -> anyhow::Result<bool> {
let Some(key) = who.key() else {
return Ok(false);
};
let store = store(state).await?;
let id = record_id(group, &key);
let Some(answer) = store.get(&id).await?.and_then(|b| serde_json::from_slice::<Answer>(&b).ok()) else {
let Some(answer) = store
.get(&id)
.await?
.and_then(|b| serde_json::from_slice::<Answer>(&b).ok())
else {
return Ok(false);
};
if answer.state == REMOVED {
@@ -296,10 +408,15 @@ pub async fn revoke(state: &crate::server::AppState, group: &str, who: &Identity
/// and their active groups come back to be their groups for the
/// session. Their `sub` is remembered, so the next login matches on
/// it before anything else.
pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::User) -> Vec<String> {
pub async fn on_signed_in(
state: &crate::server::AppState,
user: &crate::auth::User,
) -> Vec<String> {
let who = Identity::from_user(user);
let records = all(state).await;
let Ok(store) = store(state).await else { return groups_of(&records, &who) };
let Ok(store) = store(state).await else {
return groups_of(&records, &who);
};
let mut settled = Vec::with_capacity(records.len());
for answer in records {
let held = identity_of(&answer);
@@ -341,7 +458,13 @@ pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::U
mod tests {
use super::*;
fn record(group: &str, state: &str, phone: Option<&str>, email: Option<&str>, sub: Option<&str>) -> Answer {
fn record(
group: &str,
state: &str,
phone: Option<&str>,
email: Option<&str>,
sub: Option<&str>,
) -> Answer {
Answer {
id: record_id(group, "k"),
question_id: "/".into(),
@@ -353,44 +476,95 @@ mod tests {
decided_ms: None,
decided_by: None,
notes: Vec::new(),
finished: false,
}
}
#[test]
fn a_phone_number_is_one_string_for_everyone() {
assert_eq!(normalize_phone("912 34 567", "+47"), Some("+4791234567".into()));
assert_eq!(normalize_phone("+47 912-34-567", "+47"), Some("+4791234567".into()));
assert_eq!(normalize_phone("004791234567", "+47"), Some("+4791234567".into()));
assert_eq!(normalize_phone("91234567", "+46"), Some("+4691234567".into()));
assert_eq!(
normalize_phone("912 34 567", "+47"),
Some("+4791234567".into())
);
assert_eq!(
normalize_phone("+47 912-34-567", "+47"),
Some("+4791234567".into())
);
assert_eq!(
normalize_phone("004791234567", "+47"),
Some("+4791234567".into())
);
assert_eq!(
normalize_phone("91234567", "+46"),
Some("+4691234567".into())
);
assert_eq!(normalize_phone("kari@vel.no", "+47"), None);
assert_eq!(normalize_phone("123", "+47"), None);
}
#[test]
fn a_person_is_their_phone_first_and_their_address_second() {
let both = Identity::from_responses(&serde_json::json!({ "name": "Kari", "telefon": "912 34 567", "email": "Kari@Vel.no" }));
let both = Identity::from_responses(
&serde_json::json!({ "name": "Kari", "telefon": "912 34 567", "email": "Kari@Vel.no" }),
);
assert_eq!(both.key().as_deref(), Some("_4791234567"));
assert_eq!(both.email.as_deref(), Some("kari@vel.no"));
let mail_only = Identity::from_responses(&serde_json::json!({ "epost": "Kari@Vel.no" }));
assert_eq!(mail_only.key().as_deref(), Some("kari_vel_no"));
assert_eq!(Identity::from_contact("mailto:Post@Vel.no").email.as_deref(), Some("post@vel.no"));
assert_eq!(Identity::from_contact("912 34 567").phone.as_deref(), Some("+4791234567"));
assert_eq!(
Identity::from_contact("mailto:Post@Vel.no")
.email
.as_deref(),
Some("post@vel.no")
);
assert_eq!(
Identity::from_contact("912 34 567").phone.as_deref(),
Some("+4791234567")
);
assert!(Identity::default().key().is_none());
}
#[test]
fn a_record_is_about_a_person_by_any_one_of_sub_phone_or_email() {
let kari = Identity { sub: Some("abc".into()), phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
assert!(Identity { sub: None, phone: Some("+4791234567".into()), email: None }.matches(&kari));
assert!(Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) }.matches(&kari));
assert!(Identity { sub: Some("abc".into()), phone: None, email: Some("old@vel.no".into()) }.matches(&kari));
assert!(!Identity { sub: None, phone: Some("+4799999999".into()), email: Some("ola@vel.no".into()) }.matches(&kari));
let kari = Identity {
sub: Some("abc".into()),
phone: Some("+4791234567".into()),
email: Some("kari@vel.no".into()),
};
assert!(Identity {
sub: None,
phone: Some("+4791234567".into()),
email: None
}
.matches(&kari));
assert!(Identity {
sub: None,
phone: None,
email: Some("kari@vel.no".into())
}
.matches(&kari));
assert!(Identity {
sub: Some("abc".into()),
phone: None,
email: Some("old@vel.no".into())
}
.matches(&kari));
assert!(!Identity {
sub: None,
phone: Some("+4799999999".into()),
email: Some("ola@vel.no".into())
}
.matches(&kari));
assert!(!Identity::default().matches(&kari));
}
#[test]
fn only_active_memberships_are_groups() {
let kari = Identity { sub: None, phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
let kari = Identity {
sub: None,
phone: Some("+4791234567".into()),
email: Some("kari@vel.no".into()),
};
let records = vec![
record("styret", ACTIVE, Some("+4791234567"), None, None),
record("kasserer", INVITED, None, Some("kari@vel.no"), None),
+332 -68
View File
@@ -37,14 +37,25 @@ pub enum People {
impl People {
/// `None` only when Kanidm is asked for and not configured.
pub fn from_env() -> Option<Self> {
match std::env::var("PEOPLE_BACKEND").ok().map(|b| b.trim().to_lowercase()).as_deref() {
match std::env::var("PEOPLE_BACKEND")
.ok()
.map(|b| b.trim().to_lowercase())
.as_deref()
{
Some("memberships") => Some(People::Memberships),
Some("kanidm") => Kanidm::from_env().map(People::Kanidm),
Some(other) => {
tracing::warn!(backend = other, "PEOPLE_BACKEND is neither kanidm nor memberships; using memberships");
tracing::warn!(
backend = other,
"PEOPLE_BACKEND is neither kanidm nor memberships; using memberships"
);
Some(People::Memberships)
}
None => Some(Kanidm::from_env().map(People::Kanidm).unwrap_or(People::Memberships)),
None => Some(
Kanidm::from_env()
.map(People::Kanidm)
.unwrap_or(People::Memberships),
),
}
}
@@ -55,10 +66,19 @@ impl People {
/// through Kanidm an account and a one-time link, through
/// memberships a record that becomes theirs when they sign in.
/// `by` is who asked, for the record.
pub async fn invite(&self, state: &crate::server::AppState, name: &str, who: &crate::memberships::Identity, group: &str, by: &str) -> anyhow::Result<Invited> {
pub async fn invite(
&self,
state: &crate::server::AppState,
name: &str,
who: &crate::memberships::Identity,
group: &str,
by: &str,
) -> anyhow::Result<Invited> {
match self {
People::Kanidm(kanidm) => {
let email = who.email.as_deref().ok_or_else(|| anyhow::anyhow!("Kanidm knows people by their address, and this one has none"))?;
let email = who.email.as_deref().ok_or_else(|| {
anyhow::anyhow!("Kanidm knows people by their address, and this one has none")
})?;
kanidm.invite(name, email, group).await
}
People::Memberships => {
@@ -75,7 +95,9 @@ impl People {
invited.link_expires_ms = account.link_expires_ms;
invited.created = account.created;
}
Err(e) => tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}"),
Err(e) => {
tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}")
}
}
}
Ok(invited)
@@ -85,10 +107,19 @@ impl People {
/// Takes a person out of a group. Through Kanidm only what portal
/// put there (see `Invited.already_member`); memberships remember.
pub async fn remove(&self, state: &crate::server::AppState, who: &crate::memberships::Identity, username: &str, group: &str, by: &str) -> anyhow::Result<()> {
pub async fn remove(
&self,
state: &crate::server::AppState,
who: &crate::memberships::Identity,
username: &str,
group: &str,
by: &str,
) -> anyhow::Result<()> {
match self {
People::Kanidm(kanidm) => kanidm.remove_member(group, username).await,
People::Memberships => crate::memberships::revoke(state, group, who, by).await.map(|_| ()),
People::Memberships => crate::memberships::revoke(state, group, who, by)
.await
.map(|_| ()),
}
}
@@ -140,7 +171,9 @@ pub struct Invited {
fn expiry_ms(value: &serde_json::Value) -> Option<i64> {
match value {
serde_json::Value::Number(n) => n.as_i64().map(|s| s * 1000),
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s).ok().map(|t| t.timestamp_millis()),
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|t| t.timestamp_millis()),
_ => None,
}
}
@@ -163,7 +196,11 @@ fn members_from(body: &str) -> Vec<String> {
fn has_credentials_from(body: &str) -> bool {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("creds").and_then(|c| c.as_array()).map(|c| !c.is_empty()))
.and_then(|v| {
v.get("creds")
.and_then(|c| c.as_array())
.map(|c| !c.is_empty())
})
.unwrap_or(false)
}
@@ -184,7 +221,15 @@ fn person_lookup_found(status: u16, body: &str) -> Option<bool> {
/// `{"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)
v.get("entries")?
.as_array()?
.first()?
.get("attrs")?
.get("name")?
.as_array()?
.first()?
.as_str()
.map(str::to_string)
}
pub struct Kanidm {
@@ -202,13 +247,30 @@ 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())?;
let group_prefix = std::env::var("KANIDM_GROUP_PREFIX").unwrap_or_default().trim().to_string();
Some(Kanidm { base, token, group_prefix })
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())?;
let group_prefix = std::env::var("KANIDM_GROUP_PREFIX")
.unwrap_or_default()
.trim()
.to_string();
Some(Kanidm {
base,
token,
group_prefix,
})
}
async fn call(&self, method: openidconnect::reqwest::Method, path: &str, body: Option<serde_json::Value>) -> anyhow::Result<(u16, String)> {
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)
@@ -223,8 +285,15 @@ impl Kanidm {
}
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}"))
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
@@ -275,7 +344,13 @@ impl Kanidm {
/// Who is in a group, by account name.
pub async fn members(&self, group: &str) -> anyhow::Result<Vec<String>> {
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), None).await?;
let (status, text) = self
.call(
openidconnect::reqwest::Method::GET,
&format!("/v1/group/{}{group}/_attr/member", self.group_prefix),
None,
)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: reading the members of {group}: http {status}: {text}");
}
@@ -286,7 +361,11 @@ impl Kanidm {
/// service account manages, like adding them was.
pub async fn remove_member(&self, group: &str, username: &str) -> anyhow::Result<()> {
let (status, text) = self
.call(openidconnect::reqwest::Method::DELETE, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), Some(serde_json::json!([username])))
.call(
openidconnect::reqwest::Method::DELETE,
&format!("/v1/group/{}{group}/_attr/member", self.group_prefix),
Some(serde_json::json!([username])),
)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: removing {username} from {group}: http {status}: {text}");
@@ -297,7 +376,13 @@ impl Kanidm {
/// Whether Kanidm shows a credential for this person. `false` is
/// "not that it can see", never "none": see `has_credentials_from`.
pub async fn has_credentials(&self, username: &str) -> anyhow::Result<bool> {
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_status"), None).await?;
let (status, text) = self
.call(
openidconnect::reqwest::Method::GET,
&format!("/v1/person/{username}/_credential/_status"),
None,
)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: credential status of {username}: http {status}");
}
@@ -308,7 +393,11 @@ impl Kanidm {
/// passkey, and when it runs out.
pub async fn reset_link(&self, username: &str) -> anyhow::Result<(String, Option<i64>)> {
let (status, text) = self
.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/{LINK_SECONDS}"), None)
.call(
openidconnect::reqwest::Method::GET,
&format!("/v1/person/{username}/_credential/_update_intent/{LINK_SECONDS}"),
None,
)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
@@ -320,10 +409,18 @@ impl Kanidm {
expiry_time: Option<serde_json::Value>,
}
let intent: Intent = serde_json::from_str(&text)?;
Ok((format!("{}/ui/reset?token={}", self.base, intent.token), intent.expiry_time.as_ref().and_then(expiry_ms)))
Ok((
format!("{}/ui/reset?token={}", self.base, intent.token),
intent.expiry_time.as_ref().and_then(expiry_ms),
))
}
async fn onboard(&self, name: &str, email: &str, group: Option<&str>) -> anyhow::Result<Invited> {
async fn onboard(
&self,
name: &str,
email: &str,
group: Option<&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? {
@@ -351,9 +448,17 @@ impl Kanidm {
// were here before is what says whose the membership is; a
// failed read says "not before", which errs toward portal
// tidying up after itself.
already_member = !created && self.members(group).await.is_ok_and(|m| m.contains(&username));
already_member = !created
&& self
.members(group)
.await
.is_ok_and(|m| m.contains(&username));
let (status, text) = self
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), Some(serde_json::json!([username])))
.call(
openidconnect::reqwest::Method::POST,
&format!("/v1/group/{}{group}/_attr/member", self.group_prefix),
Some(serde_json::json!([username])),
)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
@@ -393,7 +498,10 @@ fn link_runs_out(answer: &crate::answers::Answer) -> i64 {
/// The account an invite record is about.
fn invited_of(answer: &crate::answers::Answer) -> Option<Invited> {
answer.responses.get("invited").and_then(|v| serde_json::from_value(v.clone()).ok())
answer
.responses
.get("invited")
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
/// Where a waiting invite goes now, if anywhere. Pure: what is known
@@ -401,7 +509,11 @@ fn invited_of(answer: &crate::answers::Answer) -> Option<Invited> {
///
/// An invite that made no account has no link to wait for: the person
/// had their way in already, and was only given a place.
pub fn invite_due(answer: &crate::answers::Answer, is_in: bool, now_ms: i64) -> Option<&'static str> {
pub fn invite_due(
answer: &crate::answers::Answer,
is_in: bool,
now_ms: i64,
) -> Option<&'static str> {
if answer.state != crate::answers::OPEN_STATE {
return None;
}
@@ -418,7 +530,11 @@ pub fn invite_due(answer: &crate::answers::Answer, is_in: bool, now_ms: i64) ->
/// An invite nobody at a desk made - the first person, someone the
/// content names as responsible - on file like any other, so the desk
/// that reads `portal_invites` sees it, and can send it again.
pub async fn record(state: &crate::server::AppState, invited: &Invited, responsible_for: &[String]) {
pub async fn record(
state: &crate::server::AppState,
invited: &Invited,
responsible_for: &[String],
) {
let now_ms = chrono::Utc::now().timestamp_millis();
let mut responses = serde_json::json!({
"name": invited.display_name,
@@ -429,23 +545,53 @@ pub async fn record(state: &crate::server::AppState, invited: &Invited, responsi
responses["responsible_for"] = serde_json::json!(responsible_for);
}
let id = crate::chain::hash_node("/", &[], &responses, now_ms);
if let Err(e) = crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), INVITES_BUCKET, id, "/", SEED_ALTERNATIVE, &responses, now_ms, 0).await {
if let Err(e) = crate::answers::store_answer(
&state.jetstream,
&state.aggregates.load(),
INVITES_BUCKET,
id,
"/",
SEED_ALTERNATIVE,
&responses,
now_ms,
0,
)
.await
{
tracing::warn!(user = %invited.username, "the invite is sent, but could not be put on file for a desk: {e}");
}
}
/// Moves an invite record, the way a desk button would: through the
/// event log, onto the projection, and out on the answers subject.
async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: crate::answers::Answer, target: &str) {
async fn move_invite(
state: &crate::server::AppState,
store: &async_nats::jetstream::kv::Store,
mut answer: crate::answers::Answer,
target: &str,
) {
let aggregates = state.aggregates.load();
let Some(schema) = aggregates.get(INVITES_BUCKET).filter(|s| s.allowed(&answer.state).iter().any(|t| t == target)) else {
let Some(schema) = aggregates
.get(INVITES_BUCKET)
.filter(|s| s.allowed(&answer.state).iter().any(|t| t == target))
else {
// A content repo may declare the bucket itself, without this
// state. Then the invite stays where its own graph has it.
return;
};
let now_ms = chrono::Utc::now().timestamp_millis();
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": BY });
if let Err(e) = crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms).await {
if let Err(e) = crate::answers::transition_or_reseed(
&state.jetstream,
schema,
&answer.id,
target,
&answer.state,
payload.clone(),
now_ms,
)
.await
{
tracing::warn!(id = %answer.id, target, "an invite could not be moved: {e}");
return;
}
@@ -454,7 +600,11 @@ async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstr
answer.decided_by = Some(BY.to_string());
// The link is a way in for as long as it is on file; one that is
// used or run out is only something to leak.
if let Some(invited) = answer.responses.get_mut("invited").and_then(|i| i.as_object_mut()) {
if let Some(invited) = answer
.responses
.get_mut("invited")
.and_then(|i| i.as_object_mut())
{
invited.insert("reset_link".into(), serde_json::Value::Null);
}
if let Ok(data) = serde_json::to_vec(&answer) {
@@ -478,17 +628,33 @@ async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstr
}
/// Every invite on file that is still waiting in one of `states`.
async fn invites_in(state: &crate::server::AppState, states: &[&str]) -> Option<(async_nats::jetstream::kv::Store, Vec<crate::answers::Answer>)> {
async fn invites_in(
state: &crate::server::AppState,
states: &[&str],
) -> Option<(
async_nats::jetstream::kv::Store,
Vec<crate::answers::Answer>,
)> {
// Made on first write: a site that has invited nobody has no bucket.
let store = state.jetstream.get_key_value(INVITES_BUCKET).await.ok()?;
let keys: Vec<String> = {
use futures::TryStreamExt;
store.keys().await.ok()?.try_collect().await.unwrap_or_default()
store
.keys()
.await
.ok()?
.try_collect()
.await
.unwrap_or_default()
};
let mut found = Vec::new();
for id in keys {
let Ok(Some(bytes)) = store.get(&id).await else { continue };
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
let Ok(Some(bytes)) = store.get(&id).await else {
continue;
};
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else {
continue;
};
if states.contains(&answer.state.as_str()) {
found.push(answer);
}
@@ -509,12 +675,17 @@ pub async fn run(state: crate::server::AppState) {
/// One pass over the waiting invites: done for whoever Kanidm shows a
/// credential for, expired for a link that ran out.
pub async fn sweep_invites(state: &crate::server::AppState) {
let Some((store, waiting)) = invites_in(state, &[crate::answers::OPEN_STATE]).await else { return };
let Some((store, waiting)) = invites_in(state, &[crate::answers::OPEN_STATE]).await else {
return;
};
let kanidm = Kanidm::from_env();
let now_ms = chrono::Utc::now().timestamp_millis();
for answer in waiting {
let is_in = match (&kanidm, invited_of(&answer)) {
(Some(kanidm), Some(invited)) if invited.created => kanidm.has_credentials(&invited.username).await.unwrap_or(false),
(Some(kanidm), Some(invited)) if invited.created => kanidm
.has_credentials(&invited.username)
.await
.unwrap_or(false),
_ => false,
};
if let Some(target) = invite_due(&answer, is_in, now_ms) {
@@ -527,14 +698,23 @@ pub async fn sweep_invites(state: &crate::server::AppState) {
/// address it went to for someone whose account goes by another name.
pub fn is_for(invited: &Invited, user: &crate::auth::User) -> bool {
let address = invited.email.trim().to_lowercase();
invited.username == user.username || (!address.is_empty() && user.email.as_deref().is_some_and(|e| e.trim().to_lowercase() == address))
invited.username == user.username
|| (!address.is_empty()
&& user
.email
.as_deref()
.is_some_and(|e| e.trim().to_lowercase() == address))
}
/// Someone signed in: whatever invites were waiting for them, or had
/// run out, are done. This is the one sure sign a link was used -
/// they are here.
pub async fn on_signed_in(state: crate::server::AppState, user: crate::auth::User) {
let Some((store, waiting)) = invites_in(&state, &[crate::answers::OPEN_STATE, INVITE_EXPIRED]).await else { return };
let Some((store, waiting)) =
invites_in(&state, &[crate::answers::OPEN_STATE, INVITE_EXPIRED]).await
else {
return;
};
for answer in waiting {
if invited_of(&answer).is_some_and(|invited| is_for(&invited, &user)) {
move_invite(&state, &store, answer, INVITE_DONE).await;
@@ -547,11 +727,20 @@ pub async fn on_signed_in(state: crate::server::AppState, user: crate::auth::Use
/// time, which for someone the content names as responsible is the one
/// that says so.
pub async fn on_reopened(state: &crate::server::AppState, id: &str) {
let Ok(store) = state.jetstream.get_key_value(INVITES_BUCKET).await else { return };
let Ok(Some(bytes)) = store.get(id).await else { return };
let Ok(mut answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { return };
let Ok(store) = state.jetstream.get_key_value(INVITES_BUCKET).await else {
return;
};
let Ok(Some(bytes)) = store.get(id).await else {
return;
};
let Ok(mut answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else {
return;
};
let Some(mut invited) = invited_of(&answer) else {
tracing::warn!(id, "an invite was sent again, but the record names no account - nothing sent");
tracing::warn!(
id,
"an invite was sent again, but the record names no account - nothing sent"
);
return;
};
let Some(kanidm) = Kanidm::from_env() else {
@@ -568,7 +757,10 @@ pub async fn on_reopened(state: &crate::server::AppState, id: &str) {
invited.reset_link = Some(link);
invited.link_expires_ms = expires;
if let Some(map) = answer.responses.as_object_mut() {
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
map.insert(
"invited".to_string(),
serde_json::to_value(&invited).unwrap_or_default(),
);
}
if let Ok(data) = serde_json::to_vec(&answer) {
let _ = store.put(id, data.into()).await;
@@ -592,7 +784,13 @@ 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 { '-' })
.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);
@@ -645,17 +843,33 @@ pub async fn on_state(
return;
};
let Some(people) = People::from_env() else {
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but {} - nobody was added", People::NOT_CONFIGURED);
tracing::warn!(
bucket,
id,
to_state,
group,
"this state grants a group, but {} - nobody was added",
People::NOT_CONFIGURED
);
return;
};
let Some((name, who)) = who_for(responses) else {
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and no phone number or email - nobody was added");
return;
};
let invited = match people.invite(state, &name, &who, &group, &format!("{bucket}:{id}")).await {
let invited = match people
.invite(state, &name, &who, &group, &format!("{bucket}:{id}"))
.await
{
Ok(invited) => invited,
Err(e) => {
tracing::error!(bucket, id, to_state, group, "granting the group failed: {e}");
tracing::error!(
bucket,
id,
to_state,
group,
"granting the group failed: {e}"
);
return;
}
};
@@ -673,14 +887,20 @@ mod tests {
#[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("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":[{"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);
@@ -688,21 +908,32 @@ mod tests {
#[test]
fn a_link_runs_out_when_kanidm_says_in_either_spelling() {
assert_eq!(expiry_ms(&serde_json::json!(1790793550)), Some(1790793550000));
assert_eq!(expiry_ms(&serde_json::json!("2026-09-30T12:00:00Z")), Some(1790769600000));
assert_eq!(
expiry_ms(&serde_json::json!(1790793550)),
Some(1790793550000)
);
assert_eq!(
expiry_ms(&serde_json::json!("2026-09-30T12:00:00Z")),
Some(1790769600000)
);
assert_eq!(expiry_ms(&serde_json::json!(null)), None);
}
#[test]
fn members_are_account_names_and_an_empty_group_is_null() {
assert_eq!(members_from(r#"["kari@id.vel.no","ola@id.vel.no"]"#), vec!["kari", "ola"]);
assert_eq!(
members_from(r#"["kari@id.vel.no","ola@id.vel.no"]"#),
vec!["kari", "ola"]
);
assert!(members_from("null").is_empty());
assert!(members_from("nonsense").is_empty());
}
#[test]
fn only_a_listed_credential_counts() {
assert!(has_credentials_from(r#"{"creds":[{"type_":"PasswordMfa"}]}"#));
assert!(has_credentials_from(
r#"{"creds":[{"type_":"PasswordMfa"}]}"#
));
assert!(!has_credentials_from(r#"{"creds":[]}"#));
assert!(!has_credentials_from("null"));
}
@@ -722,6 +953,7 @@ mod tests {
decided_ms: None,
decided_by: None,
notes: Vec::new(),
finished: false,
}
}
@@ -729,24 +961,53 @@ mod tests {
fn a_waiting_invite_is_done_when_they_are_in_and_expired_when_the_link_ran_out() {
let day = LINK_SECONDS * 1000;
// Still within the link's life, and nobody has seen them: wait.
assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 4_999), None);
assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 5_000), Some(INVITE_EXPIRED));
assert_eq!(
invite_due(&invite("open", true, Some(5_000)), false, 4_999),
None
);
assert_eq!(
invite_due(&invite("open", true, Some(5_000)), false, 5_000),
Some(INVITE_EXPIRED)
);
// In is in, however long the link had left.
assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 10), Some(INVITE_DONE));
assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 9_000), Some(INVITE_DONE));
assert_eq!(
invite_due(&invite("open", true, Some(5_000)), true, 10),
Some(INVITE_DONE)
);
assert_eq!(
invite_due(&invite("open", true, Some(5_000)), true, 9_000),
Some(INVITE_DONE)
);
// A record from before links carried their end: a day from when it was sent.
assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day - 1), None);
assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day), Some(INVITE_EXPIRED));
assert_eq!(
invite_due(&invite("open", true, None), false, 1_000 + day - 1),
None
);
assert_eq!(
invite_due(&invite("open", true, None), false, 1_000 + day),
Some(INVITE_EXPIRED)
);
// Someone who had an account was only given a place: nothing to wait for.
assert_eq!(invite_due(&invite("open", false, None), false, 10), Some(INVITE_DONE));
assert_eq!(
invite_due(&invite("open", false, None), false, 10),
Some(INVITE_DONE)
);
// Only a waiting invite moves.
assert_eq!(invite_due(&invite("expired", true, Some(5_000)), false, 9_000), None);
assert_eq!(invite_due(&invite("done", true, Some(5_000)), true, 9_000), None);
assert_eq!(
invite_due(&invite("expired", true, Some(5_000)), false, 9_000),
None
);
assert_eq!(
invite_due(&invite("done", true, Some(5_000)), true, 9_000),
None
);
}
#[test]
fn an_invite_is_theirs_by_account_or_by_address() {
let invited: Invited = serde_json::from_value(invite("open", true, None).responses["invited"].clone()).unwrap();
let invited: Invited =
serde_json::from_value(invite("open", true, None).responses["invited"].clone())
.unwrap();
let user = |username: &str, email: Option<&str>| crate::auth::User {
sub: "s".into(),
username: username.into(),
@@ -765,7 +1026,10 @@ mod tests {
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(200, r#"{"attrs":{"name":["bl"]}}"#),
Some(true)
);
assert_eq!(person_lookup_found(404, ""), Some(false));
assert_eq!(person_lookup_found(401, "unauthorized"), None);
}
+443
View File
@@ -0,0 +1,443 @@
//! An Apple configuration profile, rendered from the mail server's own
//! PACC document.
//!
//! iOS has no autoconfiguration for IMAP accounts: Apple Mail reads neither
//! Mozilla autoconfig nor Microsoft Autodiscover (those are for ActiveSync),
//! so adding an account by hand means the device probing for ports. A probe
//! that meets a *filtered* port - a firewall that drops rather than refuses -
//! waits out a TCP timeout and the account is reported as unverifiable, which
//! reads to the person as "wrong password". A profile ends the guessing: it
//! states the hosts and the ports, and carries mail, calendars and contacts in
//! one install.
//!
//! What it does not do is invent any of those values. `site.yaml` names a
//! **PACC** document (`/.well-known/user-agent-configuration.json`, which
//! Stalwart generates and which is defined to live on the mail domain), and
//! every host, port and collection path in the profile comes from there. So
//! the ports are never written down in two places, and a server that moves a
//! collection moves it in the profile too.
//!
//! The profile asks for the address and the password at install time rather
//! than embedding them: it holds nothing personal, so one document serves
//! everyone with an account and it can be fetched by anyone - it says no more
//! than the PACC document it is built from already says in public.
#[cfg(feature = "ssr")]
use crate::content::ProfileConfig;
/// The parts of a PACC document this renders from. Unknown protocols and
/// members are ignored: PACC grows, and a profile only needs what iOS can
/// be told about.
#[cfg(feature = "ssr")]
#[derive(serde::Deserialize, Default)]
struct Pacc {
#[serde(default)]
protocols: Protocols,
}
#[cfg(feature = "ssr")]
#[derive(serde::Deserialize, Default)]
struct Protocols {
#[serde(default)]
imap: Option<HostPort>,
#[serde(default)]
smtp: Option<HostPort>,
#[serde(default)]
caldav: Option<Endpoint>,
#[serde(default)]
carddav: Option<Endpoint>,
}
/// A protocol spoken on a host. PACC may state the port; when it does not,
/// the caller supplies the registered implicit-TLS default.
#[cfg(feature = "ssr")]
#[derive(serde::Deserialize)]
struct HostPort {
host: String,
#[serde(default)]
port: Option<u16>,
}
#[cfg(feature = "ssr")]
#[derive(serde::Deserialize)]
struct Endpoint {
url: String,
}
/// Host, port and path of a DAV collection URL. A collection whose URL
/// cannot be parsed is left out of the profile rather than guessed at.
#[cfg(feature = "ssr")]
fn split_url(url: &str) -> Option<(String, u16, String)> {
let rest = url.strip_prefix("https://")?;
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, "/"),
};
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) => (h.to_string(), p.parse().ok()?),
None => (authority.to_string(), 443),
};
if host.is_empty() {
return None;
}
Some((host, port, path.to_string()))
}
/// A UUID derived from what the payload is for, not drawn at random: iOS
/// replaces an installed profile whose identifier and UUID it already has, and
/// stacks a duplicate when they differ. Rendering the document twice has to
/// produce the same ids, so reinstalling updates the account instead of adding
/// a second one beside it.
#[cfg(feature = "ssr")]
fn stable_uuid(seed: &str) -> String {
use sha2::{Digest, Sha256};
let h = Sha256::digest(seed.as_bytes());
let b: Vec<String> = h.iter().take(16).map(|x| format!("{x:02X}")).collect();
let s = b.join("");
format!(
"{}-{}-{}-{}-{}",
&s[0..8],
&s[8..12],
&s[12..16],
&s[16..20],
&s[20..32]
)
}
#[cfg(feature = "ssr")]
fn esc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[cfg(feature = "ssr")]
fn string(key: &str, value: &str) -> String {
format!(
"\t\t\t<key>{key}</key>\n\t\t\t<string>{}</string>\n",
esc(value)
)
}
#[cfg(feature = "ssr")]
fn integer(key: &str, value: u16) -> String {
format!("\t\t\t<key>{key}</key>\n\t\t\t<integer>{value}</integer>\n")
}
#[cfg(feature = "ssr")]
fn boolean(key: &str, value: bool) -> String {
format!(
"\t\t\t<key>{key}</key>\n\t\t\t<{}/>\n",
if value { "true" } else { "false" }
)
}
/// Renders the profile. `domain` is the site's own host, which seeds the
/// identifiers and names the accounts on the device.
#[cfg(feature = "ssr")]
pub fn render(pacc: &str, domain: &str, cfg: &ProfileConfig) -> anyhow::Result<String> {
let pacc: Pacc = serde_json::from_str(pacc)?;
let name = cfg.name.clone().unwrap_or_else(|| domain.to_string());
let base = format!("{domain}.profile");
let mut payloads = String::new();
let mut count = 0;
// Mail. Both halves have to be there: a profile with an incoming server
// and no outgoing one installs an account that cannot send.
if let (Some(imap), Some(smtp)) = (&pacc.protocols.imap, &pacc.protocols.smtp) {
count += 1;
payloads.push_str("\t\t<dict>\n");
payloads.push_str(&string("PayloadType", "com.apple.mail.managed"));
payloads.push_str(&integer("PayloadVersion", 1));
payloads.push_str(&string("PayloadIdentifier", &format!("{base}.mail")));
payloads.push_str(&string(
"PayloadUUID",
&stable_uuid(&format!("{base}.mail")),
));
payloads.push_str(&string("PayloadDisplayName", &format!("{name} mail")));
payloads.push_str(&string("EmailAccountType", "EmailTypeIMAP"));
payloads.push_str(&string("EmailAccountDescription", &name));
payloads.push_str(&string("IncomingMailServerHostName", &imap.host));
payloads.push_str(&integer(
"IncomingMailServerPortNumber",
imap.port.unwrap_or(993),
));
payloads.push_str(&boolean("IncomingMailServerUseSSL", true));
payloads.push_str(&string(
"IncomingMailServerAuthentication",
"EmailAuthPassword",
));
payloads.push_str(&string("OutgoingMailServerHostName", &smtp.host));
payloads.push_str(&integer(
"OutgoingMailServerPortNumber",
smtp.port.unwrap_or(465),
));
payloads.push_str(&boolean("OutgoingMailServerUseSSL", true));
payloads.push_str(&string(
"OutgoingMailServerAuthentication",
"EmailAuthPassword",
));
// The one password is the same both ways, so the person types it once.
payloads.push_str(&boolean("OutgoingPasswordSameAsIncomingPassword", true));
payloads.push_str("\t\t</dict>\n");
}
for (proto, endpoint, kind, label) in [
(
"caldav",
pacc.protocols.caldav.as_ref(),
"com.apple.caldav.account",
"calendars",
),
(
"carddav",
pacc.protocols.carddav.as_ref(),
"com.apple.carddav.account",
"contacts",
),
] {
let Some((host, port, path)) = endpoint.and_then(|e| split_url(&e.url)) else {
continue;
};
count += 1;
let prefix = if proto == "caldav" {
"CalDAV"
} else {
"CardDAV"
};
payloads.push_str("\t\t<dict>\n");
payloads.push_str(&string("PayloadType", kind));
payloads.push_str(&integer("PayloadVersion", 1));
payloads.push_str(&string("PayloadIdentifier", &format!("{base}.{proto}")));
payloads.push_str(&string(
"PayloadUUID",
&stable_uuid(&format!("{base}.{proto}")),
));
payloads.push_str(&string("PayloadDisplayName", &format!("{name} {label}")));
payloads.push_str(&string(&format!("{prefix}AccountDescription"), &name));
payloads.push_str(&string(&format!("{prefix}HostName"), &host));
payloads.push_str(&integer(&format!("{prefix}Port"), port));
payloads.push_str(&boolean(&format!("{prefix}UseSSL"), true));
payloads.push_str(&string(&format!("{prefix}PrincipalURL"), &path));
payloads.push_str("\t\t</dict>\n");
}
if count == 0 {
anyhow::bail!("PACC document describes no protocol iOS can be configured with");
}
let organization = cfg.organization.clone().unwrap_or_else(|| name.clone());
Ok(format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
\t<key>PayloadType</key>\n\t<string>Configuration</string>\n\
\t<key>PayloadVersion</key>\n\t<integer>1</integer>\n\
\t<key>PayloadIdentifier</key>\n\t<string>{base}</string>\n\
\t<key>PayloadUUID</key>\n\t<string>{uuid}</string>\n\
\t<key>PayloadDisplayName</key>\n\t<string>{display}</string>\n\
\t<key>PayloadOrganization</key>\n\t<string>{org}</string>\n\
\t<key>PayloadDescription</key>\n\t<string>{desc}</string>\n\
\t<key>PayloadRemovalDisallowed</key>\n\t<false/>\n\
\t<key>PayloadContent</key>\n\t<array>\n{payloads}\t</array>\n\
</dict>\n</plist>\n",
base = esc(&base),
uuid = stable_uuid(&base),
display = esc(&name),
org = esc(&organization),
desc = esc(&format!(
"Sets up {name} on this device. You will be asked for your address and password."
)),
payloads = payloads,
))
}
/// `GET /profile.mobileconfig` - the profile for this site, or 404 when
/// `site.yaml` declares no `profile:`. Served as
/// `application/x-apple-aspen-config`, without which iOS downloads the file
/// instead of offering to install it.
#[cfg(feature = "ssr")]
pub async fn handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
) -> axum::response::Response {
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
let site = state.site.load();
let Some(cfg) = site.profile.clone() else {
return (
StatusCode::NOT_FOUND,
"this site offers no configuration profile",
)
.into_response();
};
let doc = match crate::http::client()
.get(&cfg.pacc)
.send()
.await
.and_then(|r| r.error_for_status())
{
Ok(r) => r,
Err(e) => {
tracing::warn!("profile: fetching {}: {e}", cfg.pacc);
return (StatusCode::BAD_GATEWAY, "cannot reach the mail server").into_response();
}
};
let body = match crate::http::bytes(doc, crate::http::ASSET_CAP).await {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
};
let text = match String::from_utf8(body) {
Ok(t) => t,
Err(_) => return (StatusCode::BAD_GATEWAY, "PACC document is not UTF-8").into_response(),
};
let domain = crate::mail::public_url();
let domain = domain.trim_start_matches("https://").trim_end_matches('/');
match render(&text, domain, &cfg) {
Ok(plist) => (
[
(header::CONTENT_TYPE, "application/x-apple-aspen-config"),
(header::CACHE_CONTROL, "no-cache"),
],
plist,
)
.into_response(),
Err(e) => {
tracing::warn!("profile: {e}");
(StatusCode::BAD_GATEWAY, e.to_string()).into_response()
}
}
}
#[cfg(all(test, feature = "ssr"))]
mod tests {
use super::*;
/// Stalwart 0.16's own PACC document, as served. Note what it does *not*
/// say: imap and smtp carry a host and no port, which is why the renderer
/// has to supply the registered implicit-TLS defaults.
const PACC: &str = r#"{"protocols":{"jmap":{"url":"https://mail.example.no/jmap/session"},
"imap":{"host":"mail.example.no"},"pop3":{"host":"mail.example.no"},
"smtp":{"host":"mail.example.no"},"caldav":{"url":"https://mail.example.no/dav/cal/"},
"carddav":{"url":"https://mail.example.no/dav/card/"},
"webdav":{"url":"https://mail.example.no/dav/file/"},
"managesieve":{"host":"mail.example.no"}},
"authentication":{"oauth-public":{"issuer":"https://mail.example.no"},"password":true},
"info":{"provider":{"name":"Stalwart"}}}"#;
fn cfg() -> ProfileConfig {
ProfileConfig {
pacc: "https://mail.example.no/.well-known/user-agent-configuration.json".into(),
name: Some("Example".into()),
organization: None,
}
}
#[test]
fn every_value_comes_from_the_pacc_document() {
let p = render(PACC, "example.no", &cfg()).unwrap();
assert!(p.contains("<string>com.apple.mail.managed</string>"));
// PayloadVersion is an integer everywhere, top level and in each
// payload: iOS reads the profile as a plist and the key is typed.
assert_eq!(
p.matches("<key>PayloadVersion</key>\n\t\t\t<integer>1</integer>")
.count(),
3
);
assert!(!p.contains("<key>PayloadVersion</key>\n\t\t\t<string>"));
assert!(p.contains("<string>com.apple.caldav.account</string>"));
assert!(p.contains("<string>com.apple.carddav.account</string>"));
assert!(p.contains(
"<key>IncomingMailServerHostName</key>\n\t\t\t<string>mail.example.no</string>"
));
// The ports PACC left out, filled in from the registry, not invented.
assert!(p.contains("<key>IncomingMailServerPortNumber</key>\n\t\t\t<integer>993</integer>"));
assert!(p.contains("<key>OutgoingMailServerPortNumber</key>\n\t\t\t<integer>465</integer>"));
// A DAV URL becomes a host, a port and the collection's own path.
assert!(p.contains("<key>CalDAVHostName</key>\n\t\t\t<string>mail.example.no</string>"));
assert!(p.contains("<key>CalDAVPrincipalURL</key>\n\t\t\t<string>/dav/cal/</string>"));
assert!(p.contains("<key>CardDAVPrincipalURL</key>\n\t\t\t<string>/dav/card/</string>"));
assert!(p.contains("<key>CalDAVPort</key>\n\t\t\t<integer>443</integer>"));
}
/// Nothing personal in the document: no address and no username, so iOS
/// asks at install and one profile serves everyone with an account.
#[test]
fn the_profile_holds_no_credentials() {
let p = render(PACC, "example.no", &cfg()).unwrap();
for key in [
"EmailAddress",
"IncomingMailServerUsername",
"OutgoingMailServerUsername",
"CalDAVUsername",
"CardDAVUsername",
"CalDAVPassword",
"Password",
] {
assert!(
!p.contains(&format!("<key>{key}</key>")),
"{key} must not be in the profile"
);
}
}
/// Rendering twice has to give the same ids, or a reinstall adds a second
/// account beside the first instead of replacing it.
#[test]
fn identifiers_are_stable_across_renders() {
let a = render(PACC, "example.no", &cfg()).unwrap();
let b = render(PACC, "example.no", &cfg()).unwrap();
assert_eq!(a, b);
// ...and belong to this site alone.
let other = render(PACC, "other.no", &cfg()).unwrap();
assert_ne!(a, other);
}
/// A PACC document that describes a port explicitly wins over the default.
#[test]
fn a_stated_port_is_used() {
let pacc = r#"{"protocols":{"imap":{"host":"m.example.no","port":1993},
"smtp":{"host":"m.example.no","port":1465}}}"#;
let p = render(pacc, "example.no", &cfg()).unwrap();
assert!(p.contains("<integer>1993</integer>"));
assert!(p.contains("<integer>1465</integer>"));
}
/// Half a mail account is worse than none: an account that cannot send is
/// not installed, and a document with nothing iOS understands is an error
/// rather than an empty profile that looks like it worked.
#[test]
fn an_incomplete_document_is_refused() {
let incoming_only = r#"{"protocols":{"imap":{"host":"m.example.no"}}}"#;
assert!(render(incoming_only, "example.no", &cfg()).is_err());
let jmap_only = r#"{"protocols":{"jmap":{"url":"https://m.example.no/jmap/session"}}}"#;
assert!(render(jmap_only, "example.no", &cfg()).is_err());
}
/// A name with an ampersand in it must not break the plist.
#[test]
fn names_are_escaped() {
let c = ProfileConfig {
name: Some("Smith & Co <mail>".into()),
..cfg()
};
let p = render(PACC, "example.no", &c).unwrap();
assert!(p.contains("Smith &amp; Co &lt;mail&gt;"));
assert!(!p.contains("Smith & Co"));
}
#[test]
fn a_dav_url_that_cannot_be_parsed_is_left_out() {
assert_eq!(
split_url("https://m.example.no:8443/dav/cal/"),
Some(("m.example.no".into(), 8443, "/dav/cal/".into()))
);
assert_eq!(
split_url("https://m.example.no"),
Some(("m.example.no".into(), 443, "/".into()))
);
assert_eq!(split_url("http://m.example.no/dav/cal/"), None);
assert_eq!(split_url("https://"), None);
}
}
+21 -6
View File
@@ -23,14 +23,19 @@ pub struct Report {
}
pub async fn gather(state: &crate::server::AppState) -> Report {
let mut report = Report { storylines: state.questions.load().arity(), buckets: BTreeMap::new() };
let mut report = Report {
storylines: state.questions.load().arity(),
buckets: BTreeMap::new(),
};
let aggregates = state.aggregates.load();
for bucket in aggregates.keys() {
// The runtime's own buckets are not the site's offers.
if bucket.starts_with("portal_") {
continue;
}
let Ok(store) = state.jetstream.get_key_value(bucket).await else { continue };
let Ok(store) = state.jetstream.get_key_value(bucket).await else {
continue;
};
let keys: Vec<String> = {
use futures::TryStreamExt;
match store.keys().await {
@@ -40,14 +45,24 @@ pub async fn gather(state: &crate::server::AppState) -> Report {
};
let counts = report.buckets.entry(bucket.clone()).or_default();
for key in keys {
let Ok(Some(bytes)) = store.get(&key).await else { continue };
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
*counts.entry(answer.storyline.to_string()).or_default().entry(answer.state).or_default() += 1;
let Ok(Some(bytes)) = store.get(&key).await else {
continue;
};
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else {
continue;
};
*counts
.entry(answer.storyline.to_string())
.or_default()
.entry(answer.state)
.or_default() += 1;
}
}
report
}
pub async fn handler(axum::extract::State(state): axum::extract::State<crate::server::AppState>) -> axum::Json<Report> {
pub async fn handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
) -> axum::Json<Report> {
axum::Json(gather(&state).await)
}
+310 -45
View File
@@ -27,13 +27,18 @@ pub async fn get_resource(
// `Resource::refetch()` after a fully-empty-params call failed with
// "missing field `params`", even though the very first load (always
// server-resolved) never did.
#[server(default)]
params: std::collections::HashMap<String, String>,
#[server(default)] params: std::collections::HashMap<String, String>,
) -> Result<serde_json::Value, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
let feature = find_feature(
&state,
crate::server::storyline::current().await,
&question_id,
&alternative,
&feature_name,
)?;
let resource = feature
.resource
.as_ref()
@@ -56,13 +61,18 @@ pub async fn get_requirement_options(
feature_name: String,
requirement_name: String,
// See get_resource's `params` for why this is needed.
#[server(default)]
params: std::collections::HashMap<String, String>,
#[server(default)] params: std::collections::HashMap<String, String>,
) -> Result<serde_json::Value, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
let feature = find_feature(
&state,
crate::server::storyline::current().await,
&question_id,
&alternative,
&feature_name,
)?;
let requirement = feature
.requirements
.iter()
@@ -82,7 +92,13 @@ pub async fn get_requirement_options(
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
authorize_resource(
&state,
&question_id,
&alternative,
&format!("{feature_name}|{requirement_name}"),
)
.await?;
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
}
@@ -97,13 +113,18 @@ pub async fn get_requirement_binding(
feature_name: String,
requirement_name: String,
// See get_resource's `params` for why this is needed.
#[server(default)]
params: std::collections::HashMap<String, String>,
#[server(default)] params: std::collections::HashMap<String, String>,
) -> Result<serde_json::Value, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
let feature = find_feature(
&state,
crate::server::storyline::current().await,
&question_id,
&alternative,
&feature_name,
)?;
let requirement = feature
.requirements
.iter()
@@ -113,7 +134,13 @@ pub async fn get_requirement_binding(
.bind
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
authorize_resource(
&state,
&question_id,
&alternative,
&format!("{feature_name}|{requirement_name}|bind"),
)
.await?;
fetch_resource_value(&state, &bind.resource, &as_the_person_asking(params).await).await
}
@@ -138,8 +165,9 @@ fn find_feature(
// also what substitutes the URL segment into the page's resource
// keys - so this lookup is where a `/review/<record>` page's
// feature acquires its record-specific key.
let question = crate::content::resolve_question(state.questions.load().at(storyline), question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?;
let question =
crate::content::resolve_question(state.questions.load().at(storyline), question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?;
question
.alternatives
.iter()
@@ -232,9 +260,16 @@ pub(crate) fn matches_filter(
} else {
filter.equals.clone()
};
let wanted = crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals);
let wanted =
crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals);
// Addresses compare without case; everything else as written.
let same = |found: &str| if filter.equals.contains("{user.email}") { found.trim().eq_ignore_ascii_case(&wanted) } else { found == wanted };
let same = |found: &str| {
if filter.equals.contains("{user.email}") {
found.trim().eq_ignore_ascii_case(&wanted)
} else {
found == wanted
}
};
let found = value
.get("responses")
.and_then(|r| r.get(&filter.field))
@@ -254,16 +289,24 @@ pub(crate) const USER_EMAIL: &str = "__user_email";
/// The caller's parameters with the session's person put in and
/// anything pretending to be them taken out.
#[cfg(feature = "ssr")]
pub(crate) fn with_person(mut params: std::collections::HashMap<String, String>, user: Option<&crate::auth::User>) -> std::collections::HashMap<String, String> {
pub(crate) fn with_person(
mut params: std::collections::HashMap<String, String>,
user: Option<&crate::auth::User>,
) -> std::collections::HashMap<String, String> {
params.retain(|k, _| !k.starts_with("__"));
if let Some(email) = user.and_then(|u| u.email.as_deref()).filter(|e| !e.trim().is_empty()) {
if let Some(email) = user
.and_then(|u| u.email.as_deref())
.filter(|e| !e.trim().is_empty())
{
params.insert(USER_EMAIL.to_string(), email.trim().to_lowercase());
}
params
}
#[cfg(feature = "ssr")]
async fn as_the_person_asking(params: std::collections::HashMap<String, String>) -> std::collections::HashMap<String, String> {
async fn as_the_person_asking(
params: std::collections::HashMap<String, String>,
) -> std::collections::HashMap<String, String> {
use crate::auth::{User, SESSION_USER_KEY};
let user = match leptos_axum::extract::<tower_sessions::Session>().await {
Ok(session) => session.get::<User>(SESSION_USER_KEY).await.ok().flatten(),
@@ -300,8 +343,8 @@ async fn fetch_resource_value(
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("resource key not found"))?;
let value: serde_json::Value =
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) {
@@ -322,12 +365,19 @@ 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) {
if shows_state(resource, &value) && matches_filter(resource, &value, params) {
if shows_state(resource, &value)
&& matches_filter(resource, &value, params)
{
items.push(value);
}
}
}
}
if !resource.transitions.is_empty() {
if let Some(schema) = state.aggregates.load().at(0).get(bucket) {
items = cap_finished(items, schema, finished_wanted(params));
}
}
serde_json::Value::Array(items)
}
}
@@ -336,18 +386,89 @@ async fn fetch_resource_value(
fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), params).await?
}
ResourceSource::GiteaReleases { owner, repo } => {
fetch_gitea_json(state, &format!("/api/v1/repos/{owner}/{repo}/releases"), params)
.await?
fetch_gitea_json(
state,
&format!("/api/v1/repos/{owner}/{repo}/releases"),
params,
)
.await?
}
ResourceSource::Url { url } => fetch_url_json(url, params).await?,
};
match &resource.jq {
Some(filter) => apply_jq(filter, &value),
None => Ok(value),
let shaped = match &resource.jq {
Some(filter) => apply_jq(filter, &value)?,
None => value,
};
Ok(apply_limit(resource.limit, shaped))
}
/// Keep at most `n` items, after `jq` has had its say.
///
/// Anything that is not a list passes through: a single record has no
/// length to cap, and silently turning one into an empty list would be
/// worse than ignoring a limit that cannot apply. A limit of zero does
/// mean nothing — the resource's `empty` line speaks instead, which is
/// a coherent thing for content to ask for.
#[cfg(feature = "ssr")]
fn apply_limit(limit: Option<usize>, value: serde_json::Value) -> serde_json::Value {
let Some(n) = limit else { return value };
match value {
serde_json::Value::Array(mut items) => {
items.truncate(n);
serde_json::Value::Array(items)
}
other => other,
}
}
/// How many finished records a desk listing should show: three, or
/// what the page asked for with `__finished` after "show older".
pub const FINISHED_PARAM: &str = "__finished";
pub const FINISHED_SHOWN: usize = 3;
#[cfg(feature = "ssr")]
fn finished_wanted(params: &std::collections::HashMap<String, String>) -> usize {
params
.get(FINISHED_PARAM)
.and_then(|n| n.parse().ok())
.unwrap_or(FINISHED_SHOWN)
}
/// A desk is for the cases still moving. Every record still open stays;
/// of those in a final state (no move out), only the `shown` most
/// recently decided, plus one more so the page knows there are older
/// ones to offer. Each finished record is marked, so the page can tell
/// them apart without knowing the state machine.
#[cfg(feature = "ssr")]
pub(crate) fn cap_finished(
items: Vec<serde_json::Value>,
schema: &crate::aggregates::AggregateSchema,
shown: usize,
) -> Vec<serde_json::Value> {
let is_final = |v: &serde_json::Value| {
v.get("state")
.and_then(|s| s.as_str())
.is_some_and(|s| schema.has_state(s) && schema.allowed(s).is_empty())
};
let when = |v: &serde_json::Value| {
v.get("decided_ms")
.and_then(|d| d.as_i64())
.or_else(|| v.get("submitted_ms").and_then(|d| d.as_i64()))
.unwrap_or(0)
};
let (mut finished, mut open): (Vec<_>, Vec<_>) = items.into_iter().partition(is_final);
finished.sort_by_key(|v| std::cmp::Reverse(when(v)));
finished.truncate(shown + 1);
for v in &mut finished {
if let Some(obj) = v.as_object_mut() {
obj.insert("finished".into(), true.into());
}
}
open.extend(finished);
open
}
/// Fetches JSON from a path on the same Gitea instance content is
/// loaded from (`AppState.gitea_base`), attaching `GITEA_API_TOKEN` if
/// set - confirmed live against `project.uhhm.no` that at least the
@@ -531,9 +652,16 @@ pub fn apply_jq(
let input_val = read::parse_single(input_text.as_bytes())
.map_err(|e| ServerFnError::new(format!("internal: re-parsing json for jq: {e:?}")))?;
let program = File { code: filter_src, path: () };
let defs = jaq_core::defs().chain(jaq_std::defs()).chain(jaq_json::defs());
let funs = jaq_core::funs().chain(jaq_std::funs()).chain(jaq_json::funs());
let program = File {
code: filter_src,
path: (),
};
let defs = jaq_core::defs()
.chain(jaq_std::defs())
.chain(jaq_json::defs());
let funs = jaq_core::funs()
.chain(jaq_std::funs())
.chain(jaq_json::funs());
let loader = Loader::new(defs);
let arena = Arena::default();
let modules = loader
@@ -547,7 +675,8 @@ pub fn apply_jq(
let mut outputs = Vec::new();
for result in filter.id.run((ctx, input_val)).map(unwrap_valr) {
let val = result.map_err(|e| ServerFnError::new(format!("jq filter runtime error: {e:?}")))?;
let val =
result.map_err(|e| ServerFnError::new(format!("jq filter runtime error: {e:?}")))?;
let text = val.to_string();
let json: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| ServerFnError::new(format!("internal: parsing jq output: {e}")))?;
@@ -573,16 +702,30 @@ mod tests {
#[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");
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!({}))));
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" })));
assert!(shows_state(
&decided,
&serde_json::json!({ "name": "a file" })
));
}
// The other half of a record belonging to a record: the
@@ -591,9 +734,15 @@ mod tests {
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 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" }));
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.
@@ -603,13 +752,25 @@ mod tests {
let own = spec("source: { kind: kv, bucket: cases }\nrequires_group: customers\nwhere: { field: email, equals: \"{user.email}\" }\n");
let hers = serde_json::json!({ "responses": { "email": "Kari@Vel.no" } });
let his = serde_json::json!({ "responses": { "email": "ola@vel.no" } });
let kari = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
let forged: std::collections::HashMap<String, String> = [(USER_EMAIL.to_string(), "ola@vel.no".to_string())].into_iter().collect();
let kari = crate::auth::User {
sub: "s".into(),
username: "kari".into(),
display_name: "Kari".into(),
groups: vec![],
phone: None,
email: Some("kari@vel.no".into()),
};
let forged: std::collections::HashMap<String, String> =
[(USER_EMAIL.to_string(), "ola@vel.no".to_string())]
.into_iter()
.collect();
let asked = with_person(forged.clone(), Some(&kari));
assert!(matches_filter(&own, &hers, &asked) && !matches_filter(&own, &his, &asked));
// Nobody signed in: the forged parameter is dropped, and nothing matches.
let anonymous = with_person(forged, None);
assert!(!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous));
assert!(
!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous)
);
// 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");
@@ -620,8 +781,74 @@ mod tests {
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));
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
));
}
/// `limit` caps what the page renders, and does it after `jq` so
/// content can order with the filter and take the top of its own
/// ordering. The releases card is the case: the newest cut is the
/// one you should run, and a page that grows a card per release
/// gets longer forever.
#[test]
fn limit_caps_a_list_after_the_filter() {
let releases = serde_json::json!([
{"name": "redoal 0.1.17", "tag_name": "v0.1.17", "body": "newest"},
{"name": "redoal 0.1.16", "tag_name": "v0.1.16", "body": "older"},
{"name": "redoal 0.1.15", "tag_name": "v0.1.15", "body": "oldest"}
]);
// The filter redoal/questions actually declares: bare objects,
// never a wrapped array — the frontend re-wraps.
let filter = ".[] | {name: .name, description: .body, tag: .tag_name, url: null}";
let shaped = apply_jq(filter, &releases).expect("filter runs");
let all = apply_limit(None, shaped.clone());
assert_eq!(
all.as_array().map(Vec::len),
Some(3),
"no limit shows everything"
);
let latest = apply_limit(Some(1), shaped.clone());
let rows = latest.as_array().expect("still a list");
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0]["tag"], "v0.1.17",
"and it is the newest, which Gitea returns first"
);
let two = apply_limit(Some(2), shaped.clone());
assert_eq!(two.as_array().map(Vec::len), Some(2));
// Asking for more than there is is not an error.
let plenty = apply_limit(Some(99), shaped);
assert_eq!(plenty.as_array().map(Vec::len), Some(3));
}
/// A limit that cannot apply is ignored rather than guessed at.
#[test]
fn limit_leaves_a_single_record_alone() {
let one = serde_json::json!({"name": "a record", "state": "open"});
assert_eq!(apply_limit(Some(1), one.clone()), one);
assert_eq!(apply_limit(Some(0), one.clone()), one, "no length to cap");
// Zero on a list does mean nothing — the `empty` line speaks.
let list = serde_json::json!([{"a": 1}, {"a": 2}]);
assert_eq!(apply_limit(Some(0), list).as_array().map(Vec::len), Some(0));
}
/// A canned, Gitea-API-shaped fixture - the same fields the real
@@ -718,7 +945,11 @@ mod tests {
assert_eq!(items[0]["name"], "varde bring-up");
assert_eq!(items[0]["tag"], "v0.3.0");
assert_eq!(items[0]["description"], "Embedded varde-core on iOS.");
assert_eq!(items[0]["url"], serde_json::Value::Null, "no public link while private");
assert_eq!(
items[0]["url"],
serde_json::Value::Null,
"no public link while private"
);
assert!(items[0].get("html_url").is_none());
}
@@ -740,8 +971,7 @@ mod tests {
("path".to_string(), "questions/a b.yaml".to_string()),
("ref".to_string(), "main".to_string()),
]);
let (url, leftover) =
template_url("https://x.example/api/contents/{path}", &params);
let (url, leftover) = template_url("https://x.example/api/contents/{path}", &params);
// `/` survives, the space percent-encodes, and the param that
// matched a placeholder is consumed.
assert_eq!(url, "https://x.example/api/contents/questions/a%20b.yaml");
@@ -758,3 +988,38 @@ mod tests {
assert_eq!(out, serde_json::json!(["id: /x\n"]));
}
}
#[cfg(all(test, feature = "ssr"))]
mod finished_tests {
use super::*;
#[test]
fn a_desk_keeps_every_open_case_and_the_newest_finished_ones() {
let schema = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: leads\n initial: open\n states:\n open: { event: in }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [won, lost]\n",
)
.unwrap()
.remove("leads")
.unwrap();
let rec = |id: &str, state: &str, decided: i64| serde_json::json!({ "id": id, "state": state, "submitted_ms": 0, "decided_ms": decided });
let mut items = vec![rec("o1", "open", 0), rec("o2", "open", 0)];
items.extend(
(1..=6).map(|n| rec(&format!("f{n}"), if n % 2 == 0 { "won" } else { "lost" }, n)),
);
let ids = |v: &[serde_json::Value]| {
v.iter()
.map(|x| x["id"].as_str().unwrap().to_string())
.collect::<Vec<_>>()
};
let shown = cap_finished(items.clone(), &schema, 3);
// Both open cases, then the three newest finished and one more
// to tell the page there are older ones.
assert_eq!(ids(&shown), ["o1", "o2", "f6", "f5", "f4", "f3"]);
assert!(shown[2..].iter().all(|v| v["finished"] == true));
assert!(shown[..2].iter().all(|v| v.get("finished").is_none()));
// Asking for all of them: no extra, so no "show older".
assert_eq!(cap_finished(items, &schema, 6).len(), 8);
}
}
+200 -47
View File
@@ -58,10 +58,15 @@ pub const SEED_BUCKET: &str = "portal_seed";
pub fn most_privileged_group(questions: &HashMap<String, Question>) -> Option<String> {
let mut reach: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for q in questions.values() {
let Some(group) = q.qualifies.as_deref() else { continue };
let Some(group) = q.qualifies.as_deref() else {
continue;
};
for alt in &q.alternatives {
if let Some(invite) = &alt.invite {
reach.entry(group).or_default().insert(invite.group.as_str());
reach
.entry(group)
.or_default()
.insert(invite.group.as_str());
}
}
}
@@ -73,7 +78,19 @@ pub fn most_privileged_group(questions: &HashMap<String, Question>) -> Option<St
/// The bucket key for one seed: stable, and within NATS key rules.
pub fn seed_key(email: &str, group: &str) -> String {
let clean = |s: &str| s.trim().to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect::<String>();
let clean = |s: &str| {
s.trim()
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>()
};
format!("{}.{}", clean(group), clean(email))
}
@@ -82,7 +99,9 @@ pub fn seed_key(email: &str, group: &str) -> String {
/// questions they answer for, in page order. A page a group answers
/// for (`responsible.group`) names nobody: the board's inbox is not a
/// person, and its members have their accounts through the desk.
pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<String, (String, Vec<String>)> {
pub fn responsible_people(
questions: &HashMap<String, Question>,
) -> BTreeMap<String, (String, Vec<String>)> {
let mut ids: Vec<&String> = questions.keys().collect();
ids.sort();
let mut out: BTreeMap<String, (String, Vec<String>)> = BTreeMap::new();
@@ -94,7 +113,9 @@ pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<Str
}
let email = contact_email(&r.contact);
let Some(email) = email else { continue };
let entry = out.entry(email).or_insert_with(|| (r.name.trim().to_string(), Vec::new()));
let entry = out
.entry(email)
.or_insert_with(|| (r.name.trim().to_string(), Vec::new()));
if !entry.1.contains(&q.name) {
entry.1.push(q.name.clone());
}
@@ -105,7 +126,11 @@ pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<Str
/// A responsible `contact` as an address: `mailto:` off, lowercased;
/// `None` for a phone number or anything else that is not one.
pub fn contact_email(contact: &str) -> Option<String> {
let c = contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
let c = contact
.trim()
.trim_start_matches("mailto:")
.trim()
.to_lowercase();
(c.contains('@') && !c.contains(char::is_whitespace)).then_some(c)
}
@@ -126,7 +151,10 @@ static PASS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub async fn run(state: crate::server::AppState) {
// Subscribed before the first pass, so content that changes while
// it runs is not missed.
let reloads = state.nats.subscribe(crate::content::CONTENT_RELOADED_SUBJECT).await;
let reloads = state
.nats
.subscribe(crate::content::CONTENT_RELOADED_SUBJECT)
.await;
tokio::join!(seed_admin(&state), responsible_until_done(&state), async {
let mut reloads = match reloads {
Ok(reloads) => reloads,
@@ -158,7 +186,11 @@ async fn responsible_until_done(state: &crate::server::AppState) {
tracing::error!(left, "some responsible people could not be set up, for good - until the content changes or portal starts again");
return;
}
tracing::warn!(left, attempt, "some responsible people could not be set up, trying again in a minute");
tracing::warn!(
left,
attempt,
"some responsible people could not be set up, trying again in a minute"
);
tokio::time::sleep(APART).await;
}
}
@@ -166,7 +198,14 @@ async fn responsible_until_done(state: &crate::server::AppState) {
async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jetstream::kv::Store> {
match state.jetstream.get_key_value(SEED_BUCKET).await {
Ok(store) => Some(store),
Err(_) => match state.jetstream.create_key_value(async_nats::jetstream::kv::Config { bucket: SEED_BUCKET.to_string(), ..Default::default() }).await {
Err(_) => match state
.jetstream
.create_key_value(async_nats::jetstream::kv::Config {
bucket: SEED_BUCKET.to_string(),
..Default::default()
})
.await
{
Ok(store) => Some(store),
Err(e) => {
tracing::error!("the seed bucket could not be made: {e} - making no accounts, so as not to make them twice");
@@ -177,7 +216,10 @@ async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jets
}
fn seed_admin_email() -> Option<String> {
std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@'))
std::env::var("SEED_ADMIN_EMAIL")
.ok()
.map(|e| e.trim().to_lowercase())
.filter(|e| e.contains('@'))
}
/// Who portal put in the responsible group and the content no longer
@@ -204,24 +246,37 @@ pub fn no_longer_responsible(
/// it could not finish with.
async fn responsible(state: &crate::server::AppState) -> usize {
let people = responsible_people(&state.questions.load());
let group = std::env::var("RESPONSIBLE_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty());
let group = std::env::var("RESPONSIBLE_GROUP")
.ok()
.map(|g| g.trim().to_string())
.filter(|g| !g.is_empty());
if people.is_empty() && group.is_none() {
return 0;
}
let Some(people_backend) = crate::people::People::from_env() else {
if !people.is_empty() {
tracing::info!(people = people.len(), "the content names responsible people, but {} - no accounts made", crate::people::People::NOT_CONFIGURED);
tracing::info!(
people = people.len(),
"the content names responsible people, but {} - no accounts made",
crate::people::People::NOT_CONFIGURED
);
}
return 0;
};
let Some(store) = seed_bucket(state).await else { return 0 };
let Some(store) = seed_bucket(state).await else {
return 0;
};
let mut left = 0;
for (email, (name, questions)) in &people {
let key = seed_key(email, group.as_deref().unwrap_or("responsible"));
if let Ok(Some(_)) = store.get(&key).await {
continue;
}
let name = if name.is_empty() { email.split('@').next().unwrap_or("").to_string() } else { name.clone() };
let name = if name.is_empty() {
email.split('@').next().unwrap_or("").to_string()
} else {
name.clone()
};
let who = crate::memberships::Identity::from_contact(email);
// With a group, one call makes the account if there is none and
// adds them either way; without one, only a missing account is
@@ -229,21 +284,30 @@ async fn responsible(state: &crate::server::AppState) -> usize {
// way a person is mailed only when an account was made for
// them: someone who had one already has their way in.
let made = match (&group, &people_backend) {
(Some(group), people_backend) => people_backend.invite(state, &name, &who, group, "responsible").await,
(None, crate::people::People::Kanidm(kanidm)) => match kanidm.has_person_with_mail(email).await {
Ok(true) => {
let _ = store.put(&key, "existing".into()).await;
continue;
(Some(group), people_backend) => {
people_backend
.invite(state, &name, &who, group, "responsible")
.await
}
(None, crate::people::People::Kanidm(kanidm)) => {
match kanidm.has_person_with_mail(email).await {
Ok(true) => {
let _ = store.put(&key, "existing".into()).await;
continue;
}
Ok(false) => kanidm.account(&name, email).await,
Err(e) => Err(e),
}
Ok(false) => kanidm.account(&name, email).await,
Err(e) => Err(e),
},
}
(None, crate::people::People::Memberships) => continue,
};
match made {
Ok(invited) => {
if let Ok(bytes) = serde_json::to_vec(&invited) {
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
let _ = state
.nats
.publish(crate::people::INVITED_SUBJECT, bytes.clone().into())
.await;
let _ = store.put(&key, bytes.into()).await;
}
if invited.created {
@@ -274,14 +338,21 @@ async fn responsible(state: &crate::server::AppState) -> usize {
};
let mut on_file = Vec::new();
for key in keys.into_iter().filter(|k| k.starts_with(&prefix)) {
let Ok(Some(bytes)) = store.get(&key).await else { continue };
let Ok(invited) = serde_json::from_slice::<crate::people::Invited>(&bytes) else { continue };
let Ok(Some(bytes)) = store.get(&key).await else {
continue;
};
let Ok(invited) = serde_json::from_slice::<crate::people::Invited>(&bytes) else {
continue;
};
on_file.push((key, invited));
}
for (key, invited) in no_longer_responsible(&on_file, &people, seed_admin_email().as_deref()) {
if !invited.already_member {
let who = crate::memberships::Identity::from_contact(&invited.email);
if let Err(e) = people_backend.remove(state, &who, &invited.username, &group, "responsible").await {
if let Err(e) = people_backend
.remove(state, &who, &invited.username, &group, "responsible")
.await
{
left += 1;
tracing::warn!(user = %invited.username, group, "could not take someone no longer responsible out of the group: {e}");
continue;
@@ -298,13 +369,24 @@ async fn responsible(state: &crate::server::AppState) -> usize {
/// or `SEED_ADMIN_PHONE`, for a site whose provider knows people by
/// their number.
async fn seed_admin(state: &crate::server::AppState) {
let phone = std::env::var("SEED_ADMIN_PHONE").ok().map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
let email = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@'));
let phone = std::env::var("SEED_ADMIN_PHONE")
.ok()
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty());
let email = std::env::var("SEED_ADMIN_EMAIL")
.ok()
.map(|e| e.trim().to_string())
.filter(|e| e.contains('@'));
let (Some(contact), who) = (
phone.clone().or_else(|| email.clone()),
crate::memberships::Identity {
sub: None,
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
phone: phone.as_deref().and_then(|p| {
crate::memberships::normalize_phone(
p,
&std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()),
)
}),
email: email.as_deref().map(|e| e.to_lowercase()),
},
) else {
@@ -316,7 +398,11 @@ async fn seed_admin(state: &crate::server::AppState) {
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| email.split('@').next().unwrap_or("admin").to_string());
let group = match std::env::var("SEED_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty()) {
let group = match std::env::var("SEED_GROUP")
.ok()
.map(|g| g.trim().to_string())
.filter(|g| !g.is_empty())
{
Some(g) => g,
None => match most_privileged_group(&state.questions.load()) {
Some(g) => g,
@@ -327,13 +413,22 @@ async fn seed_admin(state: &crate::server::AppState) {
},
};
let key = seed_key(&email, &group);
let Some(store) = seed_bucket(state).await else { return };
let Some(store) = seed_bucket(state).await else {
return;
};
if let Ok(Some(_)) = store.get(&key).await {
tracing::info!(group, "the first person was already invited into this group; not again");
tracing::info!(
group,
"the first person was already invited into this group; not again"
);
return;
}
let Some(people) = crate::people::People::from_env() else {
tracing::warn!(group, "SEED_ADMIN_EMAIL is set, but {} - nobody was seeded", crate::people::People::NOT_CONFIGURED);
tracing::warn!(
group,
"SEED_ADMIN_EMAIL is set, but {} - nobody was seeded",
crate::people::People::NOT_CONFIGURED
);
return;
};
@@ -343,7 +438,11 @@ async fn seed_admin(state: &crate::server::AppState) {
match people.invite(state, &name, &who, &group, "seed").await {
Ok(invited) => break invited,
Err(e) if attempt < ATTEMPTS => {
tracing::warn!(group, attempt, "seeding the first person failed, trying again in a minute: {e}");
tracing::warn!(
group,
attempt,
"seeding the first person failed, trying again in a minute: {e}"
);
tokio::time::sleep(APART).await;
}
Err(e) => {
@@ -353,7 +452,10 @@ async fn seed_admin(state: &crate::server::AppState) {
}
};
if let Ok(bytes) = serde_json::to_vec(&invited) {
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
let _ = state
.nats
.publish(crate::people::INVITED_SUBJECT, bytes.clone().into())
.await;
if let Err(e) = store.put(&key, bytes.into()).await {
tracing::error!(group, "the first person is invited, but it could not be remembered: {e} - a restart would invite them again");
}
@@ -374,7 +476,17 @@ mod tests {
.iter()
.map(|g| format!(" - name: Invite into {g}\n invite: {{ group: {g} }}\n"))
.collect();
let yaml = format!("name: Q?\n{}alternatives:\n{}", qualifies.map(|q| format!("qualifies: {q}\n")).unwrap_or_default(), if alternatives.is_empty() { " - name: A\n".to_string() } else { alternatives });
let yaml = format!(
"name: Q?\n{}alternatives:\n{}",
qualifies
.map(|q| format!("qualifies: {q}\n"))
.unwrap_or_default(),
if alternatives.is_empty() {
" - name: A\n".to_string()
} else {
alternatives
}
);
let mut q: Question = serde_yaml::from_str(&yaml).unwrap();
q.id = id.to_string();
(id.to_string(), q)
@@ -384,7 +496,11 @@ mod tests {
fn the_first_person_goes_where_the_most_invitations_can_be_made() {
let qs: HashMap<String, Question> = [
page("/", None, &[]),
page("/styret", Some("styret"), &["styret", "kasserer", "trafikk", "komiteer"]),
page(
"/styret",
Some("styret"),
&["styret", "kasserer", "trafikk", "komiteer"],
),
page("/kasserer", Some("kasserer"), &["kasserer"]),
page("/trafikk", Some("trafikk"), &["trafikk"]),
]
@@ -392,7 +508,10 @@ mod tests {
.collect();
assert_eq!(most_privileged_group(&qs).as_deref(), Some("styret"));
// A tie goes to the name that sorts first, every time.
let tie: HashMap<String, Question> = [page("/b", Some("b"), &["x"]), page("/a", Some("a"), &["y"])].into_iter().collect();
let tie: HashMap<String, Question> =
[page("/b", Some("b"), &["x"]), page("/a", Some("a"), &["y"])]
.into_iter()
.collect();
assert_eq!(most_privileged_group(&tie).as_deref(), Some("a"));
// No invitations anywhere: nobody to seed into without SEED_GROUP.
let none: HashMap<String, Question> = [page("/", None, &[])].into_iter().collect();
@@ -419,26 +538,54 @@ mod tests {
#[test]
fn whoever_the_content_stopped_naming_leaves_but_never_the_first_person() {
let people: BTreeMap<String, (String, Vec<String>)> = [("kari@vel.no".to_string(), ("Kari".to_string(), vec!["Hva?".to_string()]))].into_iter().collect();
let filed = vec![on_file("kari@vel.no", false), on_file("Ola@vel.no", false), on_file("leder@vel.no", false)];
let people: BTreeMap<String, (String, Vec<String>)> = [(
"kari@vel.no".to_string(),
("Kari".to_string(), vec!["Hva?".to_string()]),
)]
.into_iter()
.collect();
let filed = vec![
on_file("kari@vel.no", false),
on_file("Ola@vel.no", false),
on_file("leder@vel.no", false),
];
let gone = no_longer_responsible(&filed, &people, Some("leder@vel.no"));
assert_eq!(gone.iter().map(|(_, i)| i.username.as_str()).collect::<Vec<_>>(), vec!["Ola"]);
assert_eq!(
gone.iter()
.map(|(_, i)| i.username.as_str())
.collect::<Vec<_>>(),
vec!["Ola"]
);
assert_eq!(gone[0].0, "redaktor.ola_vel_no");
// Nobody named at all: everyone portal put there leaves.
assert_eq!(no_longer_responsible(&filed, &BTreeMap::new(), None).len(), 3);
assert_eq!(
no_longer_responsible(&filed, &BTreeMap::new(), None).len(),
3
);
}
#[test]
fn everyone_named_responsible_is_found_once_by_their_address() {
let mut qs: HashMap<String, Question> = HashMap::new();
for (id, name, contact) in [("/", "Hva gjør vi?", "mailto:Styret@Vel.no"), ("/melde-fra", "Hva vil du melde?", "styret@vel.no"), ("/b", "Ringe?", "+47 900 00 000"), ("/c", "Trafikk?", "trafikk@vel.no")] {
for (id, name, contact) in [
("/", "Hva gjør vi?", "mailto:Styret@Vel.no"),
("/melde-fra", "Hva vil du melde?", "styret@vel.no"),
("/b", "Ringe?", "+47 900 00 000"),
("/c", "Trafikk?", "trafikk@vel.no"),
] {
let mut q: Question = serde_yaml::from_str(&format!("name: \"{name}\"\nresponsible: {{ name: Styret, contact: \"{contact}\" }}\nalternatives:\n - name: A\n")).unwrap();
q.id = id.to_string();
qs.insert(id.to_string(), q);
}
let people = responsible_people(&qs);
assert_eq!(people.keys().collect::<Vec<_>>(), ["styret@vel.no", "trafikk@vel.no"]);
assert_eq!(people["styret@vel.no"].1, ["Hva gjør vi?", "Hva vil du melde?"]);
assert_eq!(
people.keys().collect::<Vec<_>>(),
["styret@vel.no", "trafikk@vel.no"]
);
assert_eq!(
people["styret@vel.no"].1,
["Hva gjør vi?", "Hva vil du melde?"]
);
assert_eq!(contact_email("+47 900 00 000"), None);
// A group's inbox is nobody: its members have the desk.
let mut held: Question = serde_yaml::from_str("name: \"Hva?\"\nresponsible: { name: Styret, contact: post@vel.no, group: styret }\nalternatives:\n - name: A\n").unwrap();
@@ -449,7 +596,13 @@ mod tests {
#[test]
fn a_seed_is_remembered_by_address_and_group() {
assert_eq!(seed_key(" Kari.Lien@Tomtervel.no ", "styret"), "styret.kari_lien_tomtervel_no");
assert_ne!(seed_key("kari@x.no", "styret"), seed_key("kari@x.no", "kasserer"));
assert_eq!(
seed_key(" Kari.Lien@Tomtervel.no ", "styret"),
"styret.kari_lien_tomtervel_no"
);
assert_ne!(
seed_key("kari@x.no", "styret"),
seed_key("kari@x.no", "kasserer")
);
}
}
+3 -1
View File
@@ -28,7 +28,9 @@ pub struct AppState {
/// hot-swapped alongside `questions` on the same reload. A bucket
/// with no entry here isn't event-sourced - plain KV mutate-in-place
/// still works (see `answers.rs`).
pub aggregates: Arc<ArcSwap<crate::content::Storylines<HashMap<String, crate::aggregates::AggregateSchema>>>>,
pub aggregates: Arc<
ArcSwap<crate::content::Storylines<HashMap<String, crate::aggregates::AggregateSchema>>>,
>,
/// Site branding from the content repo's optional `site.yaml`
/// (title, wordmark, hero kind) - hot-swapped with `questions`/
/// `aggregates` on the same reload. Default = the historical uhhm
+32 -14
View File
@@ -13,8 +13,8 @@ use axum::{
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims},
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
OAuth2TokenResponse, TokenResponse,
EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier,
RedirectUrl, Scope, TokenResponse,
};
use serde::Deserialize;
use tower_sessions::Session;
@@ -148,7 +148,10 @@ pub async fn login(
Query(params): Query<LoginParams>,
) -> Result<Redirect, HandlerError> {
if let Some(redirect) = params.redirect.filter(|r| is_safe_redirect(r)) {
session.insert(REDIRECT_KEY, redirect).await.map_err(internal)?;
session
.insert(REDIRECT_KEY, redirect)
.await
.map_err(internal)?;
}
let oidc = state.oidc.configured()?;
@@ -168,7 +171,10 @@ pub async fn login(
// What else to ask the provider for: `phoneNumber` for one that
// verifies numbers (Vipps). Only when asked, because Kanidm denies
// a sign-in that requests a scope the person does not hold.
for scope in std::env::var("OIDC_EXTRA_SCOPES").unwrap_or_default().split_whitespace() {
for scope in std::env::var("OIDC_EXTRA_SCOPES")
.unwrap_or_default()
.split_whitespace()
{
request = request.add_scope(Scope::new(scope.to_string()));
}
let (auth_url, csrf_state, nonce) = request.url();
@@ -206,8 +212,7 @@ pub async fn callback(
let pkce_verifier: Option<String> = session.remove(PKCE_KEY).await.map_err(internal)?;
let nonce: Option<String> = session.remove(NONCE_KEY).await.map_err(internal)?;
let (Some(stored_csrf), Some(pkce_verifier), Some(nonce)) =
(stored_csrf, pkce_verifier, nonce)
let (Some(stored_csrf), Some(pkce_verifier), Some(nonce)) = (stored_csrf, pkce_verifier, nonce)
else {
return Err((
StatusCode::BAD_REQUEST,
@@ -262,12 +267,19 @@ pub async fn callback(
// The verified phone number lives at userinfo, not in the ID token,
// for providers that give one (Vipps). A provider that has none, or
// no userinfo endpoint, leaves it empty; nothing depends on it.
let phone = match oidc.client.user_info(token_response.access_token().clone(), None) {
Ok(request) => request
.request_async(&oidc.http)
.await
.ok()
.and_then(|info: CoreUserInfoClaims| info.phone_number().map(|p| p.as_str().to_string())),
let phone = match oidc
.client
.user_info(token_response.access_token().clone(), None)
{
Ok(request) => {
request
.request_async(&oidc.http)
.await
.ok()
.and_then(|info: CoreUserInfoClaims| {
info.phone_number().map(|p| p.as_str().to_string())
})
}
Err(_) => None,
};
let mut user = User {
@@ -295,7 +307,9 @@ pub async fn callback(
.map_err(internal)?;
let redirect: Option<String> = session.remove(REDIRECT_KEY).await.map_err(internal)?;
let target = redirect.filter(|r| is_safe_redirect(r)).unwrap_or_else(|| "/".to_string());
let target = redirect
.filter(|r| is_safe_redirect(r))
.unwrap_or_else(|| "/".to_string());
// Being here is what an invite was for (people.rs): off the
// request, since nobody signing in should wait on a desk's list.
@@ -338,6 +352,10 @@ fn extract_groups_claim<T: serde::Serialize>(id_token: &T) -> Vec<String> {
payload
.get("groups")
.and_then(|g| g.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
+20 -5
View File
@@ -21,7 +21,10 @@ fn from_cookies(header: Option<&str>, arity: usize) -> Option<usize> {
header?
.split(';')
.map(str::trim)
.find_map(|kv| kv.strip_prefix(COOKIE_NAME).and_then(|rest| rest.strip_prefix('=')))
.find_map(|kv| {
kv.strip_prefix(COOKIE_NAME)
.and_then(|rest| rest.strip_prefix('='))
})
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|n| *n < arity)
}
@@ -31,14 +34,23 @@ fn pick(arity: usize) -> usize {
(uuid::Uuid::new_v4().as_u128() % arity.max(1) as u128) as usize
}
pub async fn assign(State(state): State<super::AppState>, mut req: Request, next: Next) -> Response {
pub async fn assign(
State(state): State<super::AppState>,
mut req: Request,
next: Next,
) -> Response {
let arity = state.questions.load().arity();
let held = from_cookies(req.headers().get(COOKIE).and_then(|v| v.to_str().ok()), arity);
let held = from_cookies(
req.headers().get(COOKIE).and_then(|v| v.to_str().ok()),
arity,
);
let n = held.unwrap_or_else(|| pick(arity));
req.extensions_mut().insert(Storyline(n));
let mut res = next.run(req).await;
if held.is_none() && arity > 1 {
if let Ok(value) = format!("{COOKIE_NAME}={n}; Path=/; Max-Age=31536000; SameSite=Lax").parse() {
if let Ok(value) =
format!("{COOKIE_NAME}={n}; Path=/; Max-Age=31536000; SameSite=Lax").parse()
{
res.headers_mut().append(SET_COOKIE, value);
}
}
@@ -60,7 +72,10 @@ mod tests {
#[test]
fn a_cookie_names_a_storyline_the_site_tells() {
assert_eq!(from_cookies(Some("a=b; portal_storyline=1; c=d"), 3), Some(1));
assert_eq!(
from_cookies(Some("a=b; portal_storyline=1; c=d"), 3),
Some(1)
);
assert_eq!(from_cookies(Some("portal_storyline=7"), 3), None);
assert_eq!(from_cookies(Some("portal_storylines=1"), 3), None);
assert_eq!(from_cookies(None, 3), None);
+8 -2
View File
@@ -37,7 +37,9 @@ pub struct NatsStore {
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()
f.debug_struct("NatsStore")
.field("bucket", &BUCKET)
.finish()
}
}
@@ -73,7 +75,11 @@ impl tower_sessions::SessionStore for NatsStore {
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 {
match self
.store
.create(&record.id.to_string(), bytes.into())
.await
{
Ok(_) => return Ok(()),
Err(_) => record.id = Id::default(),
}
+1628 -403
View File
File diff suppressed because it is too large Load Diff
+34 -7
View File
@@ -27,23 +27,42 @@ pub fn alternative(alt: &Value, full: bool) -> String {
out.push_str(&format!(": {d}"));
}
out.push('\n');
if let Some(button) = alt["consequence"].as_array().and_then(|c| c.first()).and_then(Value::as_str) {
if let Some(button) = alt["consequence"]
.as_array()
.and_then(|c| c.first())
.and_then(Value::as_str)
{
out.push_str(&format!(" Button: {button}\n"));
}
for e in alt["encouragements"].as_array().into_iter().flatten().filter_map(Value::as_str) {
for e in alt["encouragements"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
{
out.push_str(&format!(" {e}\n"));
}
for f in alt["features"].as_array().into_iter().flatten() {
let (name, desc, icon) = (s(&f["name"]).trim(), s(&f["description"]).trim(), s(&f["icon"]).trim());
let (name, desc, icon) = (
s(&f["name"]).trim(),
s(&f["description"]).trim(),
s(&f["icon"]).trim(),
);
let mut line = String::from(" ");
if !icon.is_empty() {
line.push_str(&format!("[icon {}] ", icon.rsplit(':').next().unwrap_or(icon)));
line.push_str(&format!(
"[icon {}] ",
icon.rsplit(':').next().unwrap_or(icon)
));
}
if !name.is_empty() {
line.push_str(name);
}
if !desc.is_empty() {
line.push_str(&format!("{}{desc}", if name.is_empty() { "" } else { ": " }));
line.push_str(&format!(
"{}{desc}",
if name.is_empty() { "" } else { ": " }
));
}
if !line.trim().is_empty() {
out.push_str(line.trim_end());
@@ -56,8 +75,16 @@ pub fn alternative(alt: &Value, full: bool) -> String {
.map(|r| {
let label = r["label"].as_str().unwrap_or_else(|| s(&r["name"]));
let hint = s(&r["placeholder"]).trim();
let opt = if r["optional"] == true { " (optional)" } else { "" };
if hint.is_empty() { format!("{label}{opt}") } else { format!("{label}{opt} e.g. {hint}") }
let opt = if r["optional"] == true {
" (optional)"
} else {
""
};
if hint.is_empty() {
format!("{label}{opt}")
} else {
format!("{label}{opt} e.g. {hint}")
}
})
.collect();
if !fields.is_empty() {
+455 -29
View File
@@ -74,7 +74,15 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
// sampling, so a score comes with its spread. 0 is the run
// every earlier score used.
let seed: u64 = value("--seed").and_then(|s| s.parse().ok()).unwrap_or(0);
print!("{}", answer_blind(&cmd, &std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?, repo.as_deref(), seed)?);
print!(
"{}",
answer_blind(
&cmd,
&std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?,
repo.as_deref(),
seed
)?
);
return Ok(());
}
let opts = parse_args()?;
@@ -103,11 +111,16 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
std::fs::write(opts.out.join("needs.yaml"), needs)?;
// A placeholder front door, so the lint gets as far as parsing
// the spec; every need fails, a parse error is what we look for.
std::fs::write(opts.out.join("questions/index.yaml"), "name: Placeholder?\n")?;
std::fs::write(
opts.out.join("questions/index.yaml"),
"name: Placeholder?\n",
)?;
let (_, report) = lint(&opts, &[])?;
if let Some(line) = report.lines().find(|l| l.contains("parsing needs.yaml")) {
eprintln!("needs round {round}: {line}");
feedback = format!("\n## Your last attempt failed to parse\n\n{line}\n\n```yaml\n{needs}\n```\n");
feedback = format!(
"\n## Your last attempt failed to parse\n\n{line}\n\n```yaml\n{needs}\n```\n"
);
continue;
}
eprintln!("needs round {round}: needs.yaml parses, frozen");
@@ -116,9 +129,14 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
break;
}
if !feedback.is_empty() {
bail!("needs.yaml never parsed in {} round(s); the replies are in {}/llm/", opts.rounds, opts.out.display());
bail!(
"needs.yaml never parsed in {} round(s); the replies are in {}/llm/",
opts.rounds,
opts.out.display()
);
}
let needs = std::fs::read_to_string(opts.out.join("needs.yaml")).context("no needs.yaml was produced")?;
let needs = std::fs::read_to_string(opts.out.join("needs.yaml"))
.context("no needs.yaml was produced")?;
// Phase 2: structure. The page-writing model sees the needs and
// never the personas: pages written with the test in view route
@@ -132,34 +150,120 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
.to_string();
let mut green = false;
let mut report = String::new();
// What the lint found, by id, round over round (`--findings`): what
// a round closed, what it left, what it broke. `None` while the
// lint is one that predates `--findings`; then the whole report
// goes back, as it always did.
let mut ledger = Ledger::default();
let mut tracked = true;
let mut undone = String::new();
std::fs::create_dir_all(opts.out.join("findings"))?;
for round in 1..=opts.rounds {
let prompt = if round == 1 {
format!(
"{REFERENCE}\n\n# Task\n\nWrite the content repo that meets this frozen `needs.yaml`: `aggregates.yaml`, `site.yaml`, and every page under `questions/` - the front door, followup pages, role-gated pages, one desk per handling group. Do not return needs.yaml.\n\n## Intake\n\n{intake}\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n{FORMAT}"
)
} else if tracked {
let (heading, files) = match named_files(&opts.out, ledger.open.values()) {
Some(files) => ("Files the findings name", dump_files(&opts.out, &files)?),
None => ("Current files", dump(&opts.out)?),
};
format!(
"{REFERENCE}\n\n# Task\n\nThe verifier rejected the repo. Below is every finding still open, each with the change that closes it. Make those changes and nothing else: findings already closed stay closed. Return only the files you change, each complete. To remove a file, return it with the single line DELETE. needs.yaml is frozen; do not return it.\n\n## Open findings\n\n{}\n{undone}## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n## {heading}\n\n{files}\n\n{FORMAT}",
ledger.open_list(round)
)
} else {
format!(
"{REFERENCE}\n\n# Task\n\nThe verifier rejected the repo below. Fix it. Return only the files you change, each complete. To remove a file, return it with the single line DELETE. needs.yaml is frozen; do not return it.\n\n## Verifier output\n\n```\n{report}\n```\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n## Current files\n\n{}\n\n{FORMAT}",
dump(&opts.out)?
)
};
let before = snapshot(&opts.out)?;
match parse_files(&llm(&opts.llm, &prompt)?) {
Ok(files) => apply(&opts.out, files)?,
Err(_) => eprintln!("structure round {round}: the reply had no file blocks"),
}
let (ok, r) = lint(&opts, &[])?;
let (ok, r, found) = lint_findings(
&opts,
&opts.out.join(format!("findings/round-{round}.jsonl")),
)?;
report = r;
let fails = report.lines().filter(|l| l.starts_with("FAIL")).count();
eprintln!("structure round {round}: {}", if ok { "green".to_string() } else { format!("{fails} FAIL line(s)") });
log.push(format!("structure round {round}: ok={ok} fails={fails}"));
let Some(found) = found else {
tracked = false;
let fails = report.lines().filter(|l| l.starts_with("FAIL")).count();
eprintln!(
"structure round {round}: {}",
if ok {
"green".to_string()
} else {
format!("{fails} FAIL line(s)")
}
);
log.push(format!("structure round {round}: ok={ok} fails={fails}"));
if ok {
green = true;
break;
}
continue;
};
let previous = ledger.clone();
let progress = ledger.update(
round,
found.into_iter().filter(|f| f.level == "fail").collect(),
);
let line = format!(
"structure round {round}: {}",
progress.summary(ledger.open.len())
);
eprintln!("{line}");
log.push(line);
undone.clear();
if ok {
green = true;
break;
}
if round == 1 {
continue;
}
// A fix that reopens what an earlier round closed is undone,
// and the model is told what it broke, rather than chasing the
// same findings round after round.
if !progress.reopened.is_empty() {
restore(&opts.out, before)?;
ledger = previous;
undone = format!(
"## Your last change was undone\n\nIt reopened findings an earlier round had closed:\n\n{}\nClose the open findings without touching what made these pass.\n\n",
progress.reopened.iter().map(|f| format!("- [{}] {}\n", f.id, f.message)).collect::<String>()
);
eprintln!(
"structure round {round}: reopened {} closed finding(s), undone",
progress.reopened.len()
);
log.push(format!(
"structure round {round}: undone (reopened {})",
progress.reopened.len()
));
continue;
}
// No progress, no more rounds: another pass at the same findings
// is how a site gets over-trained. What is left is reported.
if progress.fixed.is_empty() || ledger.open.len() >= previous.open.len() {
eprintln!("structure round {round}: no finding closed, stopping");
log.push(format!("structure round {round}: stalled"));
break;
}
}
if !green {
std::fs::write(opts.out.join("run.log"), format!("{}\n\n{report}", log.join("\n")))?;
bail!("structure never went green in {} round(s); last verifier output is in run.log", opts.rounds);
let open = if tracked {
format!("\n\nStill open:\n{}", ledger.open_list(opts.rounds + 1))
} else {
String::new()
};
std::fs::write(
opts.out.join("run.log"),
format!("{}{open}\n\n{report}", log.join("\n")),
)?;
bail!("structure did not go green; what is still open is in run.log");
}
// Phase 3: wording.
@@ -167,22 +271,48 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
let mut defects = String::new();
let mut summary = Vec::new();
for skim in [true, false] {
let tasks_path = opts.out.join(if skim { "tasks-skim.jsonl" } else { "tasks.jsonl" });
let mut args = vec!["--needs-tasks".to_string(), tasks_path.display().to_string()];
let tasks_path = opts.out.join(if skim {
"tasks-skim.jsonl"
} else {
"tasks.jsonl"
});
let mut args = vec![
"--needs-tasks".to_string(),
tasks_path.display().to_string(),
];
if skim {
args.push("--skim".to_string());
}
lint(&opts, &args)?;
let answers = answer_blind(opts.visitor.as_deref().unwrap_or(&opts.llm), &std::fs::read_to_string(&tasks_path)?, Some(&opts.out), 0)?;
let answers_path = opts.out.join(if skim { "answers-skim.jsonl" } else { "answers.jsonl" });
let answers = answer_blind(
opts.visitor.as_deref().unwrap_or(&opts.llm),
&std::fs::read_to_string(&tasks_path)?,
Some(&opts.out),
0,
)?;
let answers_path = opts.out.join(if skim {
"answers-skim.jsonl"
} else {
"answers.jsonl"
});
std::fs::write(&answers_path, answers)?;
let mut args = vec!["--needs-score".to_string(), answers_path.display().to_string()];
let mut args = vec![
"--needs-score".to_string(),
answers_path.display().to_string(),
];
if skim {
args.push("--skim".to_string());
}
let (_, scored) = lint(&opts, &args)?;
let mode = if skim { "headings only" } else { "headings and descriptions" };
for line in scored.lines().filter(|l| l.starts_with("MISROUTE") || l.starts_with("HESITANT")) {
let mode = if skim {
"headings only"
} else {
"headings and descriptions"
};
for line in scored
.lines()
.filter(|l| l.starts_with("MISROUTE") || l.starts_with("HESITANT"))
{
defects.push_str(&format!("[{mode}] {line}\n"));
}
if let Some(line) = scored.lines().find(|l| l.starts_with("wording:")) {
@@ -191,7 +321,10 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
}
eprintln!("wording round {round}: {}", summary.join(" | "));
log.push(format!("wording round {round}: {}", summary.join(" | ")));
if defects.is_empty() || round == opts.wording_rounds {
// A visitor who chose right, hesitantly, is not a defect worth a
// rewrite on its own: only a misroute is. Hesitations ride along
// as context when there is one.
if !defects.contains("MISROUTE") || round == opts.wording_rounds {
break;
}
let personas = needs_full.split("\npersonas:").nth(1).unwrap_or_default();
@@ -311,6 +444,188 @@ fn lint(opts: &Opts, extra: &[String]) -> anyhow::Result<(bool, String)> {
Ok((output.status.success(), report))
}
/// One finding as the lint's `--findings` writes it (docs/lint.md).
#[derive(Clone, Debug, serde::Deserialize)]
struct Finding {
id: String,
level: String,
code: String,
#[serde(default)]
need: String,
message: String,
#[serde(default)]
fix: Option<String>,
}
/// The lint with `--findings` into `out`, and what it found; `None` for
/// the findings when the lint predates the flag (then it is run again
/// the old way, and only its text comes back).
fn lint_findings(opts: &Opts, out: &Path) -> anyhow::Result<(bool, String, Option<Vec<Finding>>)> {
let (ok, report) = lint(opts, &["--findings".to_string(), out.display().to_string()])?;
if report.contains("unknown argument: --findings") {
let (ok, report) = lint(opts, &[])?;
return Ok((ok, report, None));
}
let raw = std::fs::read_to_string(out).unwrap_or_default();
let found = raw
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
serde_json::from_str::<Finding>(l).with_context(|| format!("reading {}", out.display()))
})
.collect::<anyhow::Result<Vec<_>>>()?;
Ok((ok, report, Some(found)))
}
/// Every failing finding by id, across rounds.
#[derive(Clone, Default)]
struct Ledger {
open: BTreeMap<String, Finding>,
/// The round each open finding was first seen in.
since: BTreeMap<String, usize>,
/// Ids a round has closed, so one that comes back is a regression,
/// not news.
closed: std::collections::BTreeSet<String>,
}
struct Progress {
fixed: Vec<Finding>,
new: Vec<Finding>,
still: usize,
reopened: Vec<Finding>,
}
impl Progress {
fn summary(&self, open: usize) -> String {
if open == 0 {
return format!("green (fixed {})", self.fixed.len());
}
format!(
"{open} open - fixed {}, still open {}, new {}, reopened {}",
self.fixed.len(),
self.still,
self.new.len(),
self.reopened.len()
)
}
}
impl Ledger {
fn update(&mut self, round: usize, fails: Vec<Finding>) -> Progress {
let now: BTreeMap<String, Finding> = fails.into_iter().map(|f| (f.id.clone(), f)).collect();
let fixed: Vec<Finding> = self
.open
.values()
.filter(|f| !now.contains_key(&f.id))
.cloned()
.collect();
let mut new = Vec::new();
let mut reopened = Vec::new();
let mut still = 0;
for (id, f) in &now {
if self.open.contains_key(id) {
still += 1;
} else if self.closed.contains(id) {
reopened.push(f.clone());
} else {
new.push(f.clone());
}
self.since.entry(id.clone()).or_insert(round);
}
for f in &fixed {
self.closed.insert(f.id.clone());
self.since.remove(&f.id);
}
self.open = now;
Progress {
fixed,
new,
still,
reopened,
}
}
/// The open findings for a prompt or a log: id, how long open, the
/// message and its fix.
fn open_list(&self, round: usize) -> String {
self.open
.values()
.map(|f| {
let age = match self.since.get(&f.id) {
Some(&first) if first + 1 < round => format!(", open since round {first}"),
_ => String::new(),
};
let scope = if f.need.is_empty() {
String::new()
} else {
format!(" {:?}", f.need)
};
format!("- [{}] {}{scope}{age}: {}\n", f.id, f.code, f.message)
})
.collect()
}
}
/// The files open findings point at - every desk page their fixes name,
/// plus aggregates.yaml - when that is enough to act on. `None` when a
/// finding is about getting around the site (a path, an entry page, a
/// missing desk) or names no page: then the model needs to see it all.
fn named_files<'a>(
out: &Path,
findings: impl Iterator<Item = &'a Finding>,
) -> Option<Vec<PathBuf>> {
const WHOLE_SITE: &[&str] = &[
"schema",
"entry-missing",
"entry-gated",
"path-missing",
"path-closed",
"path-over-budget",
"desk-missing",
"need-duplicate",
];
let mut files = vec![out.join("aggregates.yaml")];
for f in findings {
if WHOLE_SITE.contains(&f.code.as_str()) {
return None;
}
let text = format!("{} {}", f.message, f.fix.as_deref().unwrap_or_default());
for page in text.split_whitespace().filter(|w| w.starts_with('/')) {
let page = page.trim_end_matches(|c: char| {
!(c.is_alphanumeric() || c == '_' || c == '-' || c == '/')
});
let rel = page.trim_start_matches('/');
let candidates = if rel.is_empty() {
vec![out.join("questions/index.yaml")]
} else {
vec![
out.join(format!("questions/{rel}.yaml")),
out.join(format!("questions/{rel}/index.yaml")),
]
};
if let Some(path) = candidates.into_iter().find(|p| p.exists()) {
if !files.contains(&path) {
files.push(path);
}
}
}
}
Some(files)
}
/// `dump`, for the named files only.
fn dump_files(out: &Path, files: &[PathBuf]) -> anyhow::Result<String> {
let mut text = String::new();
for path in files.iter().filter(|p| p.exists()) {
let rel = path.strip_prefix(out).unwrap_or(path).display().to_string();
text.push_str(&format!(
"<<<FILE {rel}>>>\n{}<<<END>>>\n\n",
std::fs::read_to_string(path)?
));
}
Ok(text)
}
/// `<<<FILE path>>> ... <<<END>>>` blocks. Anything outside a block is
/// ignored, so a model that adds a sentence does no harm.
fn parse_files(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
@@ -363,7 +678,9 @@ fn parse_files(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
/// trusted: repo-relative, yaml, and only where content lives.
fn allowed(path: &str) -> bool {
let p = Path::new(path);
let plain = p.components().all(|c| matches!(c, std::path::Component::Normal(_)));
let plain = p
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)));
plain
&& p.extension().and_then(|e| e.to_str()) == Some("yaml")
&& (matches!(path, "aggregates.yaml" | "site.yaml") || path.starts_with("questions/"))
@@ -434,8 +751,15 @@ fn restore(out: &Path, before: Vec<(PathBuf, String)>) -> anyhow::Result<()> {
fn dump(out: &Path) -> anyhow::Result<String> {
let mut text = String::new();
for path in content_files(out)? {
let rel = path.strip_prefix(out).unwrap_or(&path).display().to_string();
text.push_str(&format!("<<<FILE {rel}>>>\n{}<<<END>>>\n\n", std::fs::read_to_string(&path)?));
let rel = path
.strip_prefix(out)
.unwrap_or(&path)
.display()
.to_string();
text.push_str(&format!(
"<<<FILE {rel}>>>\n{}<<<END>>>\n\n",
std::fs::read_to_string(&path)?
));
}
Ok(text)
}
@@ -453,7 +777,12 @@ fn dump(out: &Path) -> anyhow::Result<String> {
/// card portal renders: button, encouragement, sections with icon and
/// guidance, fields with placeholders. Headings-only tasks (`--skim`,
/// where the task's text is the heading itself) stay headings only.
pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_offset: u64) -> anyhow::Result<String> {
pub(crate) fn answer_blind(
cmd: &str,
tasks: &str,
repo: Option<&Path>,
seed_offset: u64,
) -> anyhow::Result<String> {
let mut answers = String::new();
for line in tasks.lines().filter(|l| !l.trim().is_empty()) {
let task: serde_json::Value = serde_json::from_str(line)?;
@@ -467,8 +796,14 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off
.flatten()
.map(|(k, v)| (k.as_str(), v.as_str().unwrap_or_default()))
.collect();
let skim = options.iter().all(|(name, text)| text.is_empty() || text == name);
let page = if skim { None } else { repo.and_then(|r| rate::load_page(r, task["page"].as_str().unwrap_or("/"))) };
let skim = options
.iter()
.all(|(name, text)| text.is_empty() || text == name);
let page = if skim {
None
} else {
repo.and_then(|r| rate::load_page(r, task["page"].as_str().unwrap_or("/")))
};
let seed = fnv(id).wrapping_add(seed_offset.wrapping_mul(0x9E37_79B9_7F4A_7C15) as _);
shuffle(&mut options, seed);
// Options are numbered and the answer is the number: with a
@@ -496,14 +831,25 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off
let mut head = page.as_ref().map(card::page_head).unwrap_or_default();
// What the page says before it offers anything (the opener, the
// look around): read, not picked.
if let Some(context) = task["state"]["page"].as_str().filter(|c| !c.trim().is_empty()) {
if let Some(context) = task["state"]["page"]
.as_str()
.filter(|c| !c.trim().is_empty())
{
head.push_str(&format!("The page first says:\n{context}\n\n"));
}
let prompt = format!(
"You are this person: {body}\n\n{instructions}\n{head}The page offers these options:\n\n{}\n\nWhich one do you pick? Judge only from your own situation and the wording in front of you. Give your honest confidence between 0 and 1; if two options fit you about equally, confidence is low.\nReply as JSON: {{\"choice\": <the option's number>, \"confidence\": 0.00}}",
listed.join("\n\n")
);
let reply = llm_env(cmd, &prompt, &[("FORMAT", "json"), ("SEED", &seed.to_string()), ("TEMPERATURE", "0.2")])?;
let reply = llm_env(
cmd,
&prompt,
&[
("FORMAT", "json"),
("SEED", &seed.to_string()),
("TEMPERATURE", "0.2"),
],
)?;
let parsed = simulate::first_object(&reply).unwrap_or(serde_json::Value::Null);
let names: Vec<&str> = options.iter().map(|(n, _)| *n).collect();
let raw = match &parsed["choice"] {
@@ -516,7 +862,9 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off
_ => simulate::resolve_choice(&names, &raw),
};
let confidence = parsed["confidence"].as_f64().unwrap_or(0.0);
answers.push_str(&serde_json::to_string(&serde_json::json!({ "id": id, "choice": choice, "confidence": confidence }))?);
answers.push_str(&serde_json::to_string(
&serde_json::json!({ "id": id, "choice": choice, "confidence": confidence }),
)?);
answers.push('\n');
}
Ok(answers)
@@ -524,7 +872,9 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off
/// FNV-1a over the task id: a seed that is the same on every run.
fn fnv(text: &str) -> u32 {
text.bytes().fold(0x811c_9dc5u32, |h, b| (h ^ u32::from(b)).wrapping_mul(0x0100_0193))
text.bytes().fold(0x811c_9dc5u32, |h, b| {
(h ^ u32::from(b)).wrapping_mul(0x0100_0193)
})
}
/// Fisher-Yates on a small xorshift, so the order is fixed by the seed.
@@ -591,3 +941,79 @@ mod tests {
assert!(!allowed(".gitea/workflows/deploy.yaml"));
}
}
#[cfg(test)]
mod ledger_tests {
use super::*;
fn f(id: &str, code: &str, message: &str) -> Finding {
Finding {
id: id.into(),
level: "fail".into(),
code: code.into(),
need: "hire".into(),
message: message.into(),
fix: None,
}
}
#[test]
fn a_round_is_told_what_it_closed_kept_added_and_broke() {
let mut ledger = Ledger::default();
let p = ledger.update(1, vec![f("a", "strand", "x"), f("b", "strand", "y")]);
assert_eq!(
(p.fixed.len(), p.new.len(), p.still, p.reopened.len()),
(0, 2, 0, 0)
);
let p = ledger.update(
2,
vec![f("b", "strand", "y"), f("c", "edge-unoffered", "z")],
);
assert_eq!(
(p.fixed.len(), p.new.len(), p.still, p.reopened.len()),
(1, 1, 1, 0)
);
assert_eq!(
p.summary(ledger.open.len()),
"2 open - fixed 1, still open 1, new 1, reopened 0"
);
// "a" was closed in round 2; its return is a regression.
let p = ledger.update(3, vec![f("a", "strand", "x"), f("b", "strand", "y")]);
assert_eq!(
p.reopened.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
["a"]
);
assert!(ledger
.open_list(4)
.contains("[b] strand \"hire\", open since round 1: y"));
let p = ledger.update(4, vec![]);
assert_eq!(p.summary(ledger.open.len()), "green (fixed 2)");
}
#[test]
fn a_fix_round_sees_the_pages_its_findings_name() {
let dir = std::env::temp_dir().join(format!("ledger-test-{}", std::process::id()));
std::fs::create_dir_all(dir.join("questions/desk")).unwrap();
std::fs::write(dir.join("aggregates.yaml"), "x").unwrap();
std::fs::write(dir.join("questions/desk/index.yaml"), "x").unwrap();
std::fs::write(dir.join("questions/index.yaml"), "x").unwrap();
let mut strand = f("a", "strand", "records strand in \"quoted\"");
strand.fix =
Some("add `{ from: quoted, to: won, label: ... }` to the transitions on /desk".into());
let files = named_files(&dir, [&strand].into_iter()).unwrap();
assert_eq!(
files,
vec![
dir.join("aggregates.yaml"),
dir.join("questions/desk/index.yaml")
]
);
// A finding about getting around the site needs all of it.
assert!(named_files(
&dir,
[&strand, &f("b", "path-missing", "no path")].into_iter()
)
.is_none());
std::fs::remove_dir_all(&dir).ok();
}
}
+34 -12
View File
@@ -284,7 +284,11 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
let text = std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?;
let doc: serde_yaml::Value =
serde_yaml::from_str(&text).with_context(|| format!("parsing {path}"))?;
Some(doc.get("personas").cloned().ok_or_else(|| anyhow!("{path} has no `personas:`"))?)
Some(
doc.get("personas")
.cloned()
.ok_or_else(|| anyhow!("{path} has no `personas:`"))?,
)
}
None => None,
};
@@ -323,13 +327,14 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
if let Some(list) = personas.as_mut() {
let mapped = map_personas(&cmd, &plan, list)?;
if mapped > 0 {
eprintln!("plan round {round}: {mapped} persona(s) mapped to this plan's needs");
eprintln!(
"plan round {round}: {mapped} persona(s) mapped to this plan's needs"
);
}
text.push_str("\n# Held out from every model that writes words.\n");
text.push_str(&serde_yaml::to_string(&serde_yaml::Mapping::from_iter([(
serde_yaml::Value::from("personas"),
list.clone(),
)]))?);
text.push_str(&serde_yaml::to_string(&serde_yaml::Mapping::from_iter([
(serde_yaml::Value::from("personas"), list.clone()),
]))?);
}
let whole: Plan =
serde_yaml::from_str(&text).context("the plan with its personas attached")?;
@@ -411,25 +416,42 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
/// not have, or a persona the model leaves out, stays as it was.
fn map_personas(cmd: &str, plan: &Plan, personas: &mut serde_yaml::Value) -> anyhow::Result<usize> {
let ids: Vec<&str> = plan.needs.iter().map(|n| n.id.as_str()).collect();
let Some(list) = personas.as_sequence_mut() else { return Ok(0) };
let Some(list) = personas.as_sequence_mut() else {
return Ok(0);
};
let strangers: Vec<(String, String)> = list
.iter()
.filter(|p| !ids.contains(&p["need"].as_str().unwrap_or_default()))
.map(|p| (p["id"].as_str().unwrap_or_default().to_string(), p["text"].as_str().unwrap_or_default().to_string()))
.map(|p| {
(
p["id"].as_str().unwrap_or_default().to_string(),
p["text"].as_str().unwrap_or_default().to_string(),
)
})
.filter(|(id, _)| !id.is_empty())
.collect();
if strangers.is_empty() {
return Ok(0);
}
let needs: Vec<String> = plan.needs.iter().map(|n| format!("- {}: {} - wants {}", n.id, n.who, n.wants)).collect();
let people: Vec<String> = strangers.iter().map(|(id, text)| format!("- {id}: {text}")).collect();
let needs: Vec<String> = plan
.needs
.iter()
.map(|n| format!("- {}: {} - wants {}", n.id, n.who, n.wants))
.collect();
let people: Vec<String> = strangers
.iter()
.map(|(id, text)| format!("- {id}: {text}"))
.collect();
let prompt = format!(
"A site has these needs, one per kind of visitor:\n{}\n\nThese people arrive at it:\n{}\n\nFor each person, which need is theirs? Reply with a JSON object mapping each person's id to one need id from the list, and nothing else. Leave a person out if no need fits.",
needs.join("\n"),
people.join("\n")
);
let reply = crate::trainer::llm_with(cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.0")])?;
let Some(mapping) = first_object(&reply) else { return Ok(0) };
let reply =
crate::trainer::llm_with(cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.0")])?;
let Some(mapping) = first_object(&reply) else {
return Ok(0);
};
let mut mapped = 0;
for persona in list.iter_mut() {
let id = persona["id"].as_str().unwrap_or_default().to_string();
+367 -75
View File
@@ -34,11 +34,16 @@ use crate::trainer::card;
use crate::trainer::simulate::ask;
fn flag(argv: &[String], name: &str) -> Option<String> {
argv.iter().position(|a| a == name).and_then(|i| argv.get(i + 1)).cloned()
argv.iter()
.position(|a| a == name)
.and_then(|i| argv.get(i + 1))
.cloned()
}
fn load(path: &str) -> anyhow::Result<Value> {
Ok(serde_json::from_str(&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?)?)
Ok(serde_json::from_str(
&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?,
)?)
}
/// LIX = words per sentence + 100 * long words / words.
@@ -50,7 +55,11 @@ pub fn lix(text: &str) -> f64 {
if words.is_empty() {
return 0.0;
}
let sentences = text.split(['.', '!', '?', ':', '\n']).filter(|s| s.chars().any(char::is_alphabetic)).count().max(1);
let sentences = text
.split(['.', '!', '?', ':', '\n'])
.filter(|s| s.chars().any(char::is_alphabetic))
.count()
.max(1);
let long = words.iter().filter(|w| w.chars().count() > 6).count();
words.len() as f64 / sentences as f64 + 100.0 * long as f64 / words.len() as f64
}
@@ -74,7 +83,10 @@ fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutte
};
// The cards that lead nowhere (the opener, the look around) are
// read before the options; the gutted twin loses them too.
if let Some(context) = step["context"].as_str().filter(|c| !c.trim().is_empty() && !gutted) {
if let Some(context) = step["context"]
.as_str()
.filter(|c| !c.trim().is_empty() && !gutted)
{
text.push_str(&format!("\n{context}\n"));
}
for (i, option) in step["options"].as_array().into_iter().flatten().enumerate() {
@@ -105,7 +117,12 @@ fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutte
// tells a reader how many things they will be asked, and a model
// answers "can you tell what you will be asked" with yes to that
// (measured: 2 of 2 on every need).
let fields: Vec<&str> = step["fields"].as_array().into_iter().flatten().filter_map(|f| f["label"].as_str()).collect();
let fields: Vec<&str> = step["fields"]
.as_array()
.into_iter()
.flatten()
.filter_map(|f| f["label"].as_str())
.collect();
if !gutted && !fields.is_empty() {
text.push_str(&format!(
"\nThe form under \"{}\" asks for: {}.\n",
@@ -121,13 +138,24 @@ fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutte
fn finish_page_text(mut text: String, followup: Option<&Value>) -> String {
if let Some(next) = followup {
text.push_str(&format!("\nAfter you send it, the next page says:\n{}\n", next["name"].as_str().unwrap_or_default()));
if let Some(d) = next["description"].as_str().filter(|d| !d.trim().is_empty()) {
text.push_str(&format!(
"\nAfter you send it, the next page says:\n{}\n",
next["name"].as_str().unwrap_or_default()
));
if let Some(d) = next["description"]
.as_str()
.filter(|d| !d.trim().is_empty())
{
text.push_str(&format!("{}\n", d.trim()));
}
for alt in next["alternatives"].as_array().into_iter().flatten() {
let d = alt["description"].as_str().unwrap_or_default().trim();
text.push_str(&format!("- {}{}{}\n", alt["name"].as_str().unwrap_or_default(), if d.is_empty() { "" } else { ": " }, d));
text.push_str(&format!(
"- {}{}{}\n",
alt["name"].as_str().unwrap_or_default(),
if d.is_empty() { "" } else { ": " },
d
));
}
}
text
@@ -137,12 +165,30 @@ fn finish_page_text(mut text: String, followup: Option<&Value>) -> String {
/// description, and the followup. Headings and labels are left out.
fn prose_text(step: &Value, followup: Option<&Value>) -> String {
let mut prose = vec![step["question"].as_str().unwrap_or_default().to_string()];
prose.extend(step["options"].as_array().into_iter().flatten().filter_map(|o| o["description"].as_str()).map(str::to_string));
prose.extend(
step["options"]
.as_array()
.into_iter()
.flatten()
.filter_map(|o| o["description"].as_str())
.map(str::to_string),
);
if let Some(next) = followup {
prose.extend(next["description"].as_str().map(str::to_string));
prose.extend(next["alternatives"].as_array().into_iter().flatten().filter_map(|a| a["description"].as_str()).map(str::to_string));
prose.extend(
next["alternatives"]
.as_array()
.into_iter()
.flatten()
.filter_map(|a| a["description"].as_str())
.map(str::to_string),
);
}
prose.into_iter().filter(|p| !p.trim().is_empty()).collect::<Vec<_>>().join("\n")
prose
.into_iter()
.filter(|p| !p.trim().is_empty())
.collect::<Vec<_>>()
.join("\n")
}
/// The page a visitor lands on after the step's alternative: read from
@@ -152,7 +198,11 @@ fn followup_page(repo: Option<&Path>, step: &Value) -> Option<Value> {
let repo = repo?;
let page = load_page(repo, step["page"].as_str()?)?;
let alternative = step["alternative"].as_str()?;
let action = page["alternatives"].as_array()?.iter().find(|a| a["name"] == alternative)?["action"].as_str()?;
let action = page["alternatives"]
.as_array()?
.iter()
.find(|a| a["name"] == alternative)?["action"]
.as_str()?;
load_page(repo, action)
}
@@ -187,15 +237,33 @@ fn s(v: &Value) -> String {
/// the button for it. The outcome is put in plain words from the
/// state's own name, never from the label, and the buttons are listed
/// in a seeded shuffle.
fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize, words: &Value) -> anyhow::Result<String> {
fn button_pick(
cmd: &str,
group: &str,
bucket: &str,
from: &str,
to: &str,
labels: &[&str],
seed: usize,
words: &Value,
) -> anyhow::Result<String> {
// The state in the plan's own words when the spec carries them,
// else the id made readable.
let plain = |id: &str| words[id].as_str().map(str::to_string).unwrap_or_else(|| id.replace(['_', '-'], " "));
let plain = |id: &str| {
words[id]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| id.replace(['_', '-'], " "))
};
let mut order: Vec<&str> = labels.to_vec();
// A fixed rotation per move: the right answer is not always first.
let by = seed % order.len().max(1);
order.rotate_left(by);
let listed: Vec<String> = order.iter().enumerate().map(|(i, l)| format!("{}. {l}", i + 1)).collect();
let listed: Vec<String> = order
.iter()
.enumerate()
.map(|(i, l)| format!("{}. {l}", i + 1))
.collect();
let prompt = format!(
"You work on the {} desk of a small organisation. A case in \"{}\" was \"{}\" and has just become \"{}\". The desk shows these buttons for it:\n\n{}\n\nWhich button records what just happened? Reply as JSON: {{\"choice\": <the button's number>}}",
plain(group),
@@ -206,7 +274,10 @@ fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, label
);
let reply = ask(cmd, &prompt, &seed.to_string())?;
let n = reply["choice"].as_u64().unwrap_or(0) as usize;
Ok(order.get(n.wrapping_sub(1)).map(|l| l.to_string()).unwrap_or_default())
Ok(order
.get(n.wrapping_sub(1))
.map(|l| l.to_string())
.unwrap_or_default())
}
/// Every `mail:` on a state in the repo's aggregates.yaml, as
@@ -228,7 +299,12 @@ pub(crate) fn mail_texts(repo: &Path) -> Option<Vec<(String, String, String)>> {
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
};
Some(raw_mails(repo, &doc).into_iter().map(|(name, subject, body)| (name, strip(&subject), strip(&body))).collect())
Some(
raw_mails(repo, &doc)
.into_iter()
.map(|(name, subject, body)| (name, strip(&subject), strip(&body)))
.collect(),
)
}
/// Every mail the site sends, as written: the state mails in
@@ -248,10 +324,17 @@ fn raw_mails(repo: &Path, aggregates: &Value) -> Vec<(String, String, String)> {
}
}
}
let site: Value = std::fs::read_to_string(repo.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null);
let site: Value = std::fs::read_to_string(repo.join("site.yaml"))
.ok()
.and_then(|t| serde_yaml::from_str(&t).ok())
.unwrap_or(Value::Null);
let invite = &site["mail"]["invite"];
if invite.is_object() {
out.push(("invite".to_string(), invite["subject"].as_str().unwrap_or_default().to_string(), invite["body"].as_str().unwrap_or_default().to_string()));
out.push((
"invite".to_string(),
invite["subject"].as_str().unwrap_or_default().to_string(),
invite["body"].as_str().unwrap_or_default().to_string(),
));
}
out
}
@@ -265,7 +348,12 @@ fn as_received(template: &str, site_name: &str) -> String {
out.push_str(&rest[..i]);
let after = &rest[i + 1..];
match after.find('}') {
Some(j) if after[..j].chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') && j > 0 => {
Some(j)
if after[..j]
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
&& j > 0 =>
{
out.push_str(&match &after[..j] {
"site" => "https://example.site".to_string(),
"chain" => "8f3a1c".to_string(),
@@ -287,19 +375,38 @@ fn as_received(template: &str, site_name: &str) -> String {
}
const MAIL_QUESTIONS: [(&str, &str); 3] = [
("happened", "Do you know, from this mail alone, what has happened and to what?"),
("now", "Do you know what, if anything, you can or must do now, and how?"),
("who", "Do you know who sent it, and how you would answer them?"),
(
"happened",
"Do you know, from this mail alone, what has happened and to what?",
),
(
"now",
"Do you know what, if anything, you can or must do now, and how?",
),
(
"who",
"Do you know who sent it, and how you would answer them?",
),
];
/// Three yes-or-no questions on one mail, from the person it is for.
fn mail_understanding(cmd: &str, reader: &str, subject: &str, body: &str, seed: usize) -> anyhow::Result<[bool; 3]> {
fn mail_understanding(
cmd: &str,
reader: &str,
subject: &str,
body: &str,
seed: usize,
) -> anyhow::Result<[bool; 3]> {
let prompt = format!(
"You are {reader}. This mail has just arrived:\n---\nSubject: {subject}\n\n{body}\n---\n\nAnswer three questions as yourself. Say yes only when the mail itself tells you; if you would be guessing, say no.\n{}\nReply as JSON: {{\"happened\": true or false, \"now\": true or false, \"who\": true or false}}",
MAIL_QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::<Vec<_>>().join("\n")
);
let reply = ask(cmd, &prompt, &seed.to_string())?;
Ok([reply["happened"] == true, reply["now"] == true, reply["who"] == true])
Ok([
reply["happened"] == true,
reply["now"] == true,
reply["who"] == true,
])
}
/// `/` is `questions/index.yaml`; `/a/b` is `questions/a/b.yaml` or
@@ -309,9 +416,14 @@ pub(crate) fn load_page(repo: &Path, route: &str) -> Option<Value> {
let candidates = if rel.is_empty() {
vec![repo.join("questions/index.yaml")]
} else {
vec![repo.join(format!("questions/{rel}.yaml")), repo.join(format!("questions/{rel}/index.yaml"))]
vec![
repo.join(format!("questions/{rel}.yaml")),
repo.join(format!("questions/{rel}/index.yaml")),
]
};
let text = candidates.iter().find_map(|p| std::fs::read_to_string(p).ok())?;
let text = candidates
.iter()
.find_map(|p| std::fs::read_to_string(p).ok())?;
serde_yaml::from_str(&text).ok()
}
@@ -326,7 +438,12 @@ const MAX_REQUIRED: usize = 5;
fn page_load(step: &Value) -> (usize, usize) {
(
step["options"].as_array().map_or(0, Vec::len),
step["fields"].as_array().into_iter().flatten().filter(|f| f["optional"] != true).count(),
step["fields"]
.as_array()
.into_iter()
.flatten()
.filter(|f| f["optional"] != true)
.count(),
)
}
@@ -344,7 +461,12 @@ fn understanding(cmd: &str, who: &str, page: &str, seed: usize) -> anyhow::Resul
QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::<Vec<_>>().join("\n")
);
let reply = ask(cmd, &prompt, &seed.to_string())?;
Ok([reply["which"] == true, reply["where"] == true, reply["asked"] == true, reply["next"] == true])
Ok([
reply["which"] == true,
reply["where"] == true,
reply["asked"] == true,
reply["next"] == true,
])
}
pub fn rate(argv: &[String]) -> anyhow::Result<()> {
@@ -365,7 +487,10 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
// that desk's buttons for the bucket.
let desks = desk_buttons(&model);
if !desks.is_empty() {
println!("{:<20} {:<24} {:>8} {}", "desk", "bucket", "buttons", "mistaken for");
println!(
"{:<20} {:<24} {:>8} {}",
"desk", "bucket", "buttons", "mistaken for"
);
for (page, group, bucket, moves) in &desks {
// A desk row shows only the buttons that start from the
// row's state, so those are the ones the worker chooses
@@ -374,12 +499,25 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
let mut asked = 0;
let mut mistaken = Vec::new();
for (i, (from, to, label)) in moves.iter().enumerate() {
let labels: Vec<&str> = moves.iter().filter(|(f, _, _)| f == from).map(|(_, _, l)| l.as_str()).collect();
let labels: Vec<&str> = moves
.iter()
.filter(|(f, _, _)| f == from)
.map(|(_, _, l)| l.as_str())
.collect();
if labels.len() < 2 {
continue;
}
asked += 1;
let picked = button_pick(&cmd, group, bucket, from, to, &labels, i, &model["buckets"][bucket]["words"])?;
let picked = button_pick(
&cmd,
group,
bucket,
from,
to,
&labels,
i,
&model["buckets"][bucket]["words"],
)?;
if picked == *label {
right += 1;
} else {
@@ -389,8 +527,18 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
if asked == 0 {
continue;
}
println!("{:<20} {:<24} {:>3} of {:<2} {}", page, bucket, right, asked, mistaken.join("; "));
report.insert(format!("desk {page} {bucket}"), json!({ "right": right, "of": asked, "mistaken": mistaken }));
println!(
"{:<20} {:<24} {:>3} of {:<2} {}",
page,
bucket,
right,
asked,
mistaken.join("; ")
);
report.insert(
format!("desk {page} {bucket}"),
json!({ "right": right, "of": asked, "mistaken": mistaken }),
);
}
println!();
}
@@ -404,12 +552,23 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
if let Some(mails) = repo.as_deref().and_then(mail_texts) {
if !mails.is_empty() {
let dir = repo.as_deref().unwrap_or(Path::new("."));
let site: Value = std::fs::read_to_string(dir.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null);
let site: Value = std::fs::read_to_string(dir.join("site.yaml"))
.ok()
.and_then(|t| serde_yaml::from_str(&t).ok())
.unwrap_or(Value::Null);
let site_name = site["title"].as_str().unwrap_or("the site").to_string();
let aggregates: Value = std::fs::read_to_string(dir.join("aggregates.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null);
let aggregates: Value = std::fs::read_to_string(dir.join("aggregates.yaml"))
.ok()
.and_then(|t| serde_yaml::from_str(&t).ok())
.unwrap_or(Value::Null);
let raw = raw_mails(dir, &aggregates);
println!("{:<34} {:>5} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}", "mail", "LIX", "", "words", "happened", "now", "who", "gutted twin says");
for (i, ((name, subject, body), (_, raw_subject, raw_body))) in mails.iter().zip(&raw).enumerate() {
println!(
"{:<34} {:>5} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}",
"mail", "LIX", "", "words", "happened", "now", "who", "gutted twin says"
);
for (i, ((name, subject, body), (_, raw_subject, raw_body))) in
mails.iter().zip(&raw).enumerate()
{
let text = format!("{subject}. {body}");
let score = lix(&text);
let reader = if name == "invite" {
@@ -417,30 +576,59 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
} else {
format!("someone who sent a form to {site_name} some time ago")
};
let (subject_in, body_in) = (as_received(raw_subject, &site_name), as_received(raw_body, &site_name));
let (subject_in, body_in) = (
as_received(raw_subject, &site_name),
as_received(raw_body, &site_name),
);
let got = mail_understanding(&cmd, &reader, &subject_in, &body_in, i)?;
let twin = mail_understanding(&cmd, &reader, "News", "There is news.", i)?;
let yn = |b: bool| if b { "yes" } else { "no" };
let failed: Vec<&str> = MAIL_QUESTIONS.iter().zip(twin).filter(|(_, t)| *t).map(|((k, _), _)| *k).collect();
let failed: Vec<&str> = MAIL_QUESTIONS
.iter()
.zip(twin)
.filter(|(_, t)| *t)
.map(|((k, _), _)| *k)
.collect();
println!(
"{:<34} {:>5.0} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}",
name, score, lix_band(score), text.split_whitespace().count(), yn(got[0]), yn(got[1]), yn(got[2]),
if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) }
name,
score,
lix_band(score),
text.split_whitespace().count(),
yn(got[0]),
yn(got[1]),
yn(got[2]),
if failed.is_empty() {
"ok".to_string()
} else {
format!("FAILED: {}", failed.join(", "))
}
);
report.insert(
format!("mail {name}"),
json!({
"lix": score, "words": text.split_whitespace().count(),
"understanding": { "happened": got[0], "now": got[1], "who": got[2] },
"control_ok": failed.is_empty(),
}),
);
report.insert(format!("mail {name}"), json!({
"lix": score, "words": text.split_whitespace().count(),
"understanding": { "happened": got[0], "now": got[1], "who": got[2] },
"control_ok": failed.is_empty(),
}));
}
println!();
}
}
println!("{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}", "need", "LIX", "", "which", "where", "asked", "next", "gutted twin says", "page load");
println!(
"{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}",
"need", "LIX", "", "which", "where", "asked", "next", "gutted twin says", "page load"
);
for need in model["needs"].as_array().into_iter().flatten() {
let id = need["id"].as_str().unwrap_or_default();
let steps = need["steps"].as_array().cloned().unwrap_or_default();
let personas: Vec<&Value> = model["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).collect();
let personas: Vec<&Value> = model["personas"]
.as_array()
.into_iter()
.flatten()
.filter(|p| p["need"] == id)
.collect();
if personas.is_empty() {
continue;
}
@@ -450,15 +638,30 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
// after sending.
let mut pages = Vec::new();
for (i, step) in steps.iter().enumerate() {
let followup = if i + 1 == steps.len() { followup_page(repo.as_deref(), step) } else { None };
let page = repo.as_deref().and_then(|r| load_page(r, step["page"].as_str().unwrap_or("/")));
let row = if steps.len() > 1 { format!("{id} @{}", step["page"].as_str().unwrap_or_default()) } else { id.to_string() };
let followup = if i + 1 == steps.len() {
followup_page(repo.as_deref(), step)
} else {
None
};
let page = repo
.as_deref()
.and_then(|r| load_page(r, step["page"].as_str().unwrap_or("/")));
let row = if steps.len() > 1 {
format!("{id} @{}", step["page"].as_str().unwrap_or_default())
} else {
id.to_string()
};
let readability = lix(&prose_text(step, followup.as_ref()));
let (mut yes, mut control) = ([0usize; N], [0usize; N]);
for (i, persona) in personas.iter().enumerate() {
let who = persona["text"].as_str().unwrap_or_default();
for (tally, gutted) in [(&mut yes, false), (&mut control, true)] {
let answers = understanding(&cmd, who, &page_text(step, page.as_ref(), followup.as_ref(), gutted), i)?;
let answers = understanding(
&cmd,
who,
&page_text(step, page.as_ref(), followup.as_ref(), gutted),
i,
)?;
for (slot, answer) in tally.iter_mut().zip(answers) {
*slot += answer as usize;
}
@@ -466,12 +669,25 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
}
let n = personas.len();
let measured = |k: usize| k != 3 || followup.is_some();
let share = |k: usize| if measured(k) { format!("{}/{n}", yes[k]) } else { "-".to_string() };
let share = |k: usize| {
if measured(k) {
format!("{}/{n}", yes[k])
} else {
"-".to_string()
}
};
// The twin has no headings, no descriptions, no cards and no
// followup: every question must collapse on it, or that
// question is agreeing with anything.
let control_ok: Vec<bool> = (0..N).map(|k| !measured(k) || control[k] * 2 < n.max(1)).collect();
let failed: Vec<&str> = QUESTIONS.iter().zip(&control_ok).filter(|(_, ok)| !**ok).map(|((k, _), _)| *k).collect();
let control_ok: Vec<bool> = (0..N)
.map(|k| !measured(k) || control[k] * 2 < n.max(1))
.collect();
let failed: Vec<&str> = QUESTIONS
.iter()
.zip(&control_ok)
.filter(|(_, ok)| !**ok)
.map(|((k, _), _)| *k)
.collect();
let (options, required) = page_load(step);
println!(
"{:<28} {:>6.0} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}",
@@ -487,12 +703,24 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
control[0],
control[1],
control[2],
if measured(3) { control[3].to_string() } else { "-".to_string() },
if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) }
if measured(3) {
control[3].to_string()
} else {
"-".to_string()
},
if failed.is_empty() {
"ok".to_string()
} else {
format!("FAILED: {}", failed.join(", "))
}
),
format!(
"{options} options, {required} required{}",
if options > MAX_OPTIONS || required > MAX_REQUIRED { " - SPLIT" } else { "" }
if options > MAX_OPTIONS || required > MAX_REQUIRED {
" - SPLIT"
} else {
""
}
),
);
pages.push(json!({
@@ -505,9 +733,15 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
"control_ok": failed.is_empty(),
}));
}
report.insert(id.to_string(), json!({ "steps": pages.len(), "pages": pages }));
report.insert(
id.to_string(),
json!({ "steps": pages.len(), "pages": pages }),
);
}
std::fs::write(out.join("rate.json"), serde_json::to_string_pretty(&report)?)?;
std::fs::write(
out.join("rate.json"),
serde_json::to_string_pretty(&report)?,
)?;
Ok(())
}
@@ -517,22 +751,49 @@ pub fn prefer(argv: &[String]) -> anyhow::Result<()> {
let b = load(&flag(argv, "--b").ok_or_else(|| anyhow!(usage))?)?;
let cmd = flag(argv, "--llm").unwrap_or_else(|| "claude -p".to_string());
println!("{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}", "need", "A", "B", "no preference", "A", "B", "no preference", "LIX A->B");
println!(
"{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}",
"need", "A", "B", "no preference", "A", "B", "no preference", "LIX A->B"
);
println!("{:<28} {:^26} {:^26}", "", "friendlier", "clearer");
for need_a in a["needs"].as_array().into_iter().flatten() {
let id = need_a["id"].as_str().unwrap_or_default();
let Some(need_b) = b["needs"].as_array().into_iter().flatten().find(|n| n["id"] == id) else { continue };
let (Some(step_a), Some(step_b)) = (need_a["steps"].as_array().and_then(|s| s.first()), need_b["steps"].as_array().and_then(|s| s.first())) else { continue };
let (text_a, text_b) = (page_text(step_a, None, None, false), page_text(step_b, None, None, false));
let Some(need_b) = b["needs"]
.as_array()
.into_iter()
.flatten()
.find(|n| n["id"] == id)
else {
continue;
};
let (Some(step_a), Some(step_b)) = (
need_a["steps"].as_array().and_then(|s| s.first()),
need_b["steps"].as_array().and_then(|s| s.first()),
) else {
continue;
};
let (text_a, text_b) = (
page_text(step_a, None, None, false),
page_text(step_b, None, None, false),
);
if text_a == text_b {
continue;
}
// [friendlier, clearer] x [A, B, none]
let mut wins = [[0usize; 3]; 2];
for (i, persona) in a["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).enumerate() {
for (i, persona) in a["personas"]
.as_array()
.into_iter()
.flatten()
.filter(|p| p["need"] == id)
.enumerate()
{
let who = persona["text"].as_str().unwrap_or_default();
let mut picks: [[&str; 2]; 2] = [["", ""], ["", ""]];
for (order, (first, second)) in [(&text_a, &text_b), (&text_b, &text_a)].into_iter().enumerate() {
for (order, (first, second)) in [(&text_a, &text_b), (&text_b, &text_a)]
.into_iter()
.enumerate()
{
let prompt = format!(
"You are this person: {who}\n\nTwo versions of the same web page. They lead to the same thing; only the wording differs.\n\n=== VERSION 1 ===\n{first}\n=== VERSION 2 ===\n{second}\n\nAs yourself: which version makes you feel more welcome and at ease, and which one makes it clearer what to do?\nReply as JSON: {{\"friendlier\": 1 or 2, \"clearer\": 1 or 2}}"
);
@@ -554,7 +815,13 @@ pub fn prefer(argv: &[String]) -> anyhow::Result<()> {
}
println!(
"{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}",
id, wins[0][0], wins[0][1], wins[0][2], wins[1][0], wins[1][1], wins[1][2],
id,
wins[0][0],
wins[0][1],
wins[0][2],
wins[1][0],
wins[1][1],
wins[1][2],
format!("{:.0}->{:.0}", lix(&text_a), lix(&text_b))
);
}
@@ -592,29 +859,46 @@ mod tests {
{"name": "Get a price", "description": "For owners.", "consequence": ["Send it"], "encouragements": ["We answer within two days."], "features": [{"name": "Your brief", "requirements": [{"name": "email", "label": "Email"}]}]},
{"name": "Work with us", "description": "For crews."}]});
let rich = page_text(&step, Some(&repo_page), Some(&next), false);
assert!(rich.contains("Button: Send it") && rich.contains("We answer within two days.") && rich.contains("Asks for: Email"));
assert!(
rich.contains("Button: Send it")
&& rich.contains("We answer within two days.")
&& rich.contains("Asks for: Email")
);
assert!(!rich.contains("asks for: Email, The build"));
let twin = page_text(&step, Some(&repo_page), Some(&next), true);
assert!(!twin.contains("Send it") && !twin.contains("Your brief"));
let twin = page_text(&step, None, Some(&next), true);
assert!(twin.contains("What brings you here?") && twin.contains("Option 2"));
assert!(!twin.contains("asks for"));
assert!(!twin.contains("Get a price") && !twin.contains("Email") && !twin.contains("project manager"));
assert!(
!twin.contains("Get a price")
&& !twin.contains("Email")
&& !twin.contains("project manager")
);
// Readability is measured on the sentences, not the headings.
let prose = prose_text(&step, Some(&next));
assert_eq!(prose, "What brings you here?\nFor owners.\nFor crews.\nA project manager reads it.");
assert_eq!(
prose,
"What brings you here?\nFor owners.\nFor crews.\nA project manager reads it."
);
}
#[test]
fn a_mail_is_read_as_it_arrives_and_the_invite_is_one_of_them() {
let got = as_received("Hei {name}, se {site}/decide/x?chain={chain}. {link} {ikke en nøkkel}", "Tomter Vel");
let got = as_received(
"Hei {name}, se {site}/decide/x?chain={chain}. {link} {ikke en nøkkel}",
"Tomter Vel",
);
assert_eq!(got, "Hei [name], se https://example.site/decide/x?chain=8f3a1c. https://id.example.site/reset/8f3a1c {ikke en nøkkel}");
let dir = std::env::temp_dir().join(format!("portal-trainer-mails-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("aggregates.yaml"), "aggregates:\n - bucket: b\n initial: open\n states:\n open: { event: received, mail: { to: submitter, subject: Fått, body: \"Vi har det, {name}.\" } }\n").unwrap();
std::fs::write(dir.join("site.yaml"), "title: T\nmail: { from: x@y.no, invite: { subject: Konto, body: \"Hei {name}\\n{link}\" } }\n").unwrap();
let mails = mail_texts(&dir).unwrap();
assert_eq!(mails.iter().map(|m| m.0.as_str()).collect::<Vec<_>>(), ["b:open", "invite"]);
assert_eq!(
mails.iter().map(|m| m.0.as_str()).collect::<Vec<_>>(),
["b:open", "invite"]
);
assert_eq!(mails[0].2, "Vi har det, .");
std::fs::remove_dir_all(dir).ok();
}
@@ -623,8 +907,16 @@ mod tests {
fn the_followup_page_is_read_from_the_repo_by_following_the_action() {
let dir = std::env::temp_dir().join(format!("portal-trainer-rate-{}", std::process::id()));
std::fs::create_dir_all(dir.join("questions/received")).unwrap();
std::fs::write(dir.join("questions/index.yaml"), "name: What?\nalternatives:\n - name: Get a price\n action: /received/price\n").unwrap();
std::fs::write(dir.join("questions/received/price.yaml"), "name: What happens now?\ndescription: We read it.\nalternatives:\n - name: Back\n").unwrap();
std::fs::write(
dir.join("questions/index.yaml"),
"name: What?\nalternatives:\n - name: Get a price\n action: /received/price\n",
)
.unwrap();
std::fs::write(
dir.join("questions/received/price.yaml"),
"name: What happens now?\ndescription: We read it.\nalternatives:\n - name: Back\n",
)
.unwrap();
let step = json!({"page": "/", "alternative": "Get a price"});
let next = followup_page(Some(&dir), &step).unwrap();
assert_eq!(next["name"], "What happens now?");
+10 -1
View File
@@ -190,7 +190,16 @@ not done. Its messages are exact:
- `no desk gated on "g" reads "b"`: add the desk.
- `"g" cannot move a record to "s"` / `records strand in "s"`: the desk
is missing a transition the state machine allows, or the state
machine is missing the edge.
machine is missing the edge. The message ends in `fix:` with the
exact `{ from, to, label }` and the desk page to put it on - do that.
- `state "s" is never reached`: nothing moves a record into it. Offer
the move it names, or remove the state.
- `stage "b" comes after "a" ... but no move leads on`: the machine
cannot go the way the business works; add the edge it names.
- `the mail on entering "s" uses {x}`: the mail names a field no form
collects. Use one of the fields it lists, or collect `x`.
- WARN `the move "a" -> "b" is declared but no desk ... offers it`:
offer it, or drop the edge - every edge should be a button.
- `action ... does not match any declared question id`: the page does
not exist.
- `entry page ... names no one responsible`: add `responsible`.
+84 -17
View File
@@ -47,19 +47,35 @@ struct Showing {
/// Per bucket, per storyline index, how many arrived and how many
/// ended in a state the need calls success (or, without `success`,
/// any finished state).
fn showings(counts: &BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>, needs: &[Need]) -> BTreeMap<String, Vec<Showing>> {
fn showings(
counts: &BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>,
needs: &[Need],
) -> BTreeMap<String, Vec<Showing>> {
let mut out = BTreeMap::new();
for (bucket, per_story) in counts {
let well: Vec<&str> = needs
.iter()
.filter(|n| &n.lands_in == bucket)
.flat_map(|n| if n.success.is_empty() { n.done_when.iter() } else { n.success.iter() })
.flat_map(|n| {
if n.success.is_empty() {
n.done_when.iter()
} else {
n.success.iter()
}
})
.map(String::as_str)
.collect();
let arity = per_story.keys().filter_map(|k| k.parse::<usize>().ok()).max().map(|m| m + 1).unwrap_or(1);
let arity = per_story
.keys()
.filter_map(|k| k.parse::<usize>().ok())
.max()
.map(|m| m + 1)
.unwrap_or(1);
let mut rows: Vec<Showing> = (0..arity).map(|_| Showing::default()).collect();
for (story, states) in per_story {
let Ok(n) = story.parse::<usize>() else { continue };
let Ok(n) = story.parse::<usize>() else {
continue;
};
for (state, count) in states {
rows[n].arrived += count;
if well.contains(&state.as_str()) {
@@ -73,16 +89,32 @@ fn showings(counts: &BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>,
}
pub fn run(argv: &[String]) -> anyhow::Result<()> {
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
let site = value("--site").ok_or_else(|| anyhow!("usage: portal-trainer report --site <https://the.site> [--needs <needs.yaml>]"))?;
let value = |flag: &str| {
argv.iter()
.position(|a| a == flag)
.and_then(|i| argv.get(i + 1))
.cloned()
};
let site = value("--site").ok_or_else(|| {
anyhow!("usage: portal-trainer report --site <https://the.site> [--needs <needs.yaml>]")
})?;
let url = format!("{}/report", site.trim_end_matches('/'));
let fetched = std::process::Command::new("curl").args(["-sSf", "--max-time", "30", &url]).output().context("running curl")?;
let fetched = std::process::Command::new("curl")
.args(["-sSf", "--max-time", "30", &url])
.output()
.context("running curl")?;
if !fetched.status.success() {
bail!("could not read {url}: {}", String::from_utf8_lossy(&fetched.stderr).trim());
bail!(
"could not read {url}: {}",
String::from_utf8_lossy(&fetched.stderr).trim()
);
}
let report: Report = serde_json::from_slice(&fetched.stdout).with_context(|| format!("reading {url}"))?;
let report: Report =
serde_json::from_slice(&fetched.stdout).with_context(|| format!("reading {url}"))?;
let needs: Needs = match value("--needs") {
Some(path) => serde_yaml::from_str(&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?)?,
Some(path) => serde_yaml::from_str(
&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?,
)?,
None => Needs::default(),
};
println!("{site}: {} storyline(s)", report.storylines);
@@ -97,11 +129,26 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
for (bucket, per_story) in &rows {
println!("\n{bucket}");
for (n, showing) in per_story.iter().enumerate() {
let rate = if showing.arrived > 0 { format!("{:.0}%", 100.0 * showing.ended_well as f64 / showing.arrived as f64) } else { "-".to_string() };
println!(" storyline {n}: {} arrived, {} ended well ({rate})", showing.arrived, showing.ended_well);
let rate = if showing.arrived > 0 {
format!(
"{:.0}%",
100.0 * showing.ended_well as f64 / showing.arrived as f64
)
} else {
"-".to_string()
};
println!(
" storyline {n}: {} arrived, {} ended well ({rate})",
showing.arrived, showing.ended_well
);
}
if per_story.len() > 1 {
let best = per_story.iter().enumerate().max_by_key(|(_, s)| (s.ended_well, s.arrived)).map(|(n, _)| n).unwrap_or(0);
let best = per_story
.iter()
.enumerate()
.max_by_key(|(_, s)| (s.ended_well, s.arrived))
.map(|(n, _)| n)
.unwrap_or(0);
println!(" most so far: storyline {best}");
}
}
@@ -116,12 +163,32 @@ mod tests {
fn a_storyline_is_scored_on_what_arrived_and_what_ended_well() {
let counts: BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>> =
serde_json::from_str(r#"{"hazards": {"0": {"open": 3, "fixed": 4}, "1": {"open": 1, "fixed": 9, "dropped": 2}}}"#).unwrap();
let needs = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec!["fixed".into()] }];
let needs = vec![Need {
lands_in: "hazards".into(),
done_when: vec!["fixed".into(), "dropped".into()],
success: vec!["fixed".into()],
}];
let rows = showings(&counts, &needs);
assert_eq!(rows["hazards"][0], Showing { arrived: 7, ended_well: 4 });
assert_eq!(rows["hazards"][1], Showing { arrived: 12, ended_well: 9 });
assert_eq!(
rows["hazards"][0],
Showing {
arrived: 7,
ended_well: 4
}
);
assert_eq!(
rows["hazards"][1],
Showing {
arrived: 12,
ended_well: 9
}
);
// Without a success list, any finished state counts.
let any = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec![] }];
let any = vec![Need {
lands_in: "hazards".into(),
done_when: vec!["fixed".into(), "dropped".into()],
success: vec![],
}];
assert_eq!(showings(&counts, &any)["hazards"][1].ended_well, 11);
}
}
+77 -14
View File
@@ -11,7 +11,12 @@ use std::path::{Path, PathBuf};
use std::process::Command;
pub fn run(argv: &[String]) -> anyhow::Result<()> {
let flag = |name: &str| argv.iter().position(|a| a == name).and_then(|i| argv.get(i + 1)).cloned();
let flag = |name: &str| {
argv.iter()
.position(|a| a == name)
.and_then(|i| argv.get(i + 1))
.cloned()
};
let usage = "usage: portal-trainer score --site <content dir> --out <dir> [--iris <path>] [--llm 'cmd']";
let site = PathBuf::from(flag("--site").ok_or_else(|| anyhow!(usage))?);
let out = PathBuf::from(flag("--out").ok_or_else(|| anyhow!(usage))?);
@@ -27,52 +32,102 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
let tasks_skim = out.join("tasks-skim.jsonl");
let model = out.join("model.json");
let mut log = String::new();
log.push_str(&lint(&iris, &questions, &["--needs-tasks", &s(&tasks_full), "--needs-sim", &s(&model)])?);
log.push_str(&lint(&iris, &questions, &["--needs-tasks", &s(&tasks_skim), "--skim"])?);
log.push_str(&lint(
&iris,
&questions,
&["--needs-tasks", &s(&tasks_full), "--needs-sim", &s(&model)],
)?);
log.push_str(&lint(
&iris,
&questions,
&["--needs-tasks", &s(&tasks_skim), "--skim"],
)?);
std::fs::write(out.join("iris.log"), &log)?;
let mut summary = serde_json::Map::new();
for (mode, tasks, skim) in [("full", &tasks_full, false), ("skim", &tasks_skim, true)] {
eprintln!("score: the visitor answers {mode}");
let answers = crate::trainer::answer_blind(&cmd, &std::fs::read_to_string(tasks)?, Some(&site), 0)?;
let answers =
crate::trainer::answer_blind(&cmd, &std::fs::read_to_string(tasks)?, Some(&site), 0)?;
let answers_path = out.join(format!("answers-{mode}.jsonl"));
std::fs::write(&answers_path, &answers)?;
let (tasks_arg, answers_arg) = (s(tasks), s(&answers_path));
let mut args = vec!["--needs-tasks", tasks_arg.as_str(), "--needs-score", answers_arg.as_str()];
let mut args = vec![
"--needs-tasks",
tasks_arg.as_str(),
"--needs-score",
answers_arg.as_str(),
];
if skim {
args.push("--skim");
}
let report = lint(&iris, &questions, &args)?;
println!("== {}", if skim { "headings only" } else { "with descriptions" });
println!(
"== {}",
if skim {
"headings only"
} else {
"with descriptions"
}
);
let mut right = None;
for line in report.lines() {
if line.starts_with("MISROUTE") || line.starts_with("HESITANT") || line.starts_with("UNANSWERED") {
if line.starts_with("MISROUTE")
|| line.starts_with("HESITANT")
|| line.starts_with("UNANSWERED")
{
println!("{line}");
}
if let Some(rest) = line.strip_prefix("wording: ") {
// "wording: 19/27 routed right (70%)"
let mut parts = rest.split_whitespace().next().unwrap_or_default().split('/');
let mut parts = rest
.split_whitespace()
.next()
.unwrap_or_default()
.split('/');
let a: usize = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
let b: usize = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
right = Some((a, b));
println!("{line}");
}
}
let (a, b) = right.ok_or_else(|| anyhow!("iris printed no wording score for {mode}; its output:\n{report}"))?;
let (a, b) = right.ok_or_else(|| {
anyhow!("iris printed no wording score for {mode}; its output:\n{report}")
})?;
summary.insert(mode.to_string(), serde_json::json!({ "right": a, "of": b }));
}
println!("== rate");
let rated = out.join("rated");
let rate_argv: Vec<String> = ["rate", "--model", &s(&model), "--out", &s(&rated), "--repo", &s(&site), "--llm", &cmd].iter().map(|x| x.to_string()).collect();
let rate_argv: Vec<String> = [
"rate",
"--model",
&s(&model),
"--out",
&s(&rated),
"--repo",
&s(&site),
"--llm",
&cmd,
]
.iter()
.map(|x| x.to_string())
.collect();
crate::trainer::rate::rate(&rate_argv)?;
summary.insert("site".into(), serde_json::json!(s(&site)));
summary.insert("visitor".into(), serde_json::json!(cmd));
std::fs::write(out.join("summary.json"), serde_json::to_string_pretty(&serde_json::Value::Object(summary.clone()))?)?;
std::fs::write(
out.join("summary.json"),
serde_json::to_string_pretty(&serde_json::Value::Object(summary.clone()))?,
)?;
println!(
"== summary: with descriptions {} of {}, headings only {} of {} (rate.json in {})",
summary["full"]["right"], summary["full"]["of"], summary["skim"]["right"], summary["skim"]["of"], rated.display()
summary["full"]["right"],
summary["full"]["of"],
summary["skim"]["right"],
summary["skim"]["of"],
rated.display()
);
Ok(())
}
@@ -92,9 +147,17 @@ fn lint(iris: &str, questions: &Path, args: &[&str]) -> anyhow::Result<String> {
.args(args)
.output()
.with_context(|| format!("running {iris} (pass --iris <path> if it is not on PATH)"))?;
let text = format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if !output.status.success() {
bail!("{iris} check --path {} {} failed:\n{text}", questions.display(), args.join(" "));
bail!(
"{iris} check --path {} {} failed:\n{text}",
questions.display(),
args.join(" ")
);
}
Ok(text)
}
+183 -37
View File
@@ -127,7 +127,12 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
continue;
}
let bucket = &model["buckets"][need["lands_in"].as_str().unwrap_or_default()];
let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
let done: Vec<&str> = need["done_when"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
for (run, route) in all_routes(bucket, &done).into_iter().enumerate() {
let mut states = vec![bucket["initial"].as_str().unwrap_or("open").to_string()];
states.extend(route.iter().map(|m| m.to.clone()));
@@ -137,7 +142,10 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
run,
target: states.last().cloned().unwrap_or_default(),
record: BTreeMap::from([("note".to_string(), json!("dry run"))]),
pressed: route.iter().map(|m| format!("{} -> {}", m.from, m.label)).collect(),
pressed: route
.iter()
.map(|m| format!("{} -> {}", m.from, m.label))
.collect(),
moved_by: route.iter().map(|m| m.by.clone()).collect(),
touches: route.len(),
ask_backs: Vec::new(),
@@ -148,9 +156,16 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
writeln!(cases_file, "{}", serde_json::to_string(&case)?)?;
}
}
std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?;
std::fs::write(
out.join("score.json"),
serde_json::to_string_pretty(&scores)?,
)?;
let relays: usize = scores.values().map(|s| s.cases).sum();
println!("dry run: {relays} route(s) across {} need(s) written to {}", scores.len(), out.join("cases.jsonl").display());
println!(
"dry run: {relays} route(s) across {} need(s) written to {}",
scores.len(),
out.join("cases.jsonl").display()
);
return Ok(());
}
@@ -180,7 +195,10 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
writeln!(cases_file, "{}", serde_json::to_string(&case)?)?;
}
}
std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?;
std::fs::write(
out.join("score.json"),
serde_json::to_string_pretty(&scores)?,
)?;
print_scores(&scores);
Ok(())
}
@@ -257,7 +275,8 @@ fn play(
);
let reply = ask(visitor_cmd, &prompt, &seed(i * 10 + 1))?;
if reply["abandon"] == true {
case.outcome = Outcome::Abandoned(reply["why"].as_str().unwrap_or_default().to_string());
case.outcome =
Outcome::Abandoned(reply["why"].as_str().unwrap_or_default().to_string());
return Ok(case);
}
if let Some(values) = reply["values"].as_object() {
@@ -277,9 +296,18 @@ fn play(
// collected, could this role really do what the button claims?
let bucket_name = need["lands_in"].as_str().unwrap_or_default();
let bucket = &model["buckets"][bucket_name];
let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
let done: Vec<&str> = need["done_when"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
let initial = bucket["initial"].as_str().unwrap_or("open").to_string();
let target = done.get(run % done.len().max(1)).copied().unwrap_or_default().to_string();
let target = done
.get(run % done.len().max(1))
.copied()
.unwrap_or_default()
.to_string();
case.target = target.clone();
case.states.push(initial.clone());
let Some(route) = route_to(bucket, &initial, &target) else {
@@ -304,7 +332,11 @@ fn play(
serde_json::to_string_pretty(&case.record)?,
);
let reply = ask(handler_cmd, &prompt, &seed(100 + step))?;
let question = reply["need_to_know"].as_str().unwrap_or_default().trim().to_string();
let question = reply["need_to_know"]
.as_str()
.unwrap_or_default()
.trim()
.to_string();
if reply["can_act"] == false && !question.is_empty() {
let answer = ask(
visitor_cmd,
@@ -314,7 +346,10 @@ fn play(
&seed(200 + step),
)?;
let n = case.ask_backs.len() + 1;
case.record.insert(format!("follow_up_{n}"), json!({ "asked": question, "answered": answer["answer"] }));
case.record.insert(
format!("follow_up_{n}"),
json!({ "asked": question, "answered": answer["answer"] }),
);
case.ask_backs.push(AskBack {
state: from.clone(),
button: label.clone(),
@@ -343,21 +378,47 @@ pub(crate) struct Move {
/// desk, and the submitter's own link. Roles overlap on one aggregate,
/// so a route is a relay, not one desk's work.
pub(crate) fn all_moves(bucket: &Value) -> Vec<Move> {
let legal = |from: &str, to: &str| bucket["transitions"][from].as_array().is_some_and(|a| a.iter().any(|x| x == to));
let legal = |from: &str, to: &str| {
bucket["transitions"][from]
.as_array()
.is_some_and(|a| a.iter().any(|x| x == to))
};
let mut moves: Vec<Move> = Vec::new();
for desk in bucket["desks"].as_array().into_iter().flatten() {
for m in desk["moves"].as_array().into_iter().flatten() {
let (Some(from), Some(label), Some(to)) = (m["from"].as_str(), m["label"].as_str(), m["to"].as_str()) else { continue };
let (Some(from), Some(label), Some(to)) =
(m["from"].as_str(), m["label"].as_str(), m["to"].as_str())
else {
continue;
};
if legal(from, to) && !moves.iter().any(|x| x.from == from && x.to == to) {
moves.push(Move { from: from.into(), label: label.into(), to: to.into(), by: desk["group"].as_str().unwrap_or("ungated").into() });
moves.push(Move {
from: from.into(),
label: label.into(),
to: to.into(),
by: desk["group"].as_str().unwrap_or("ungated").into(),
});
}
}
}
let states: Vec<String> = bucket["transitions"].as_object().map(|t| t.keys().cloned().collect()).unwrap_or_default();
for to in bucket["self_moves"].as_array().into_iter().flatten().filter_map(Value::as_str) {
let states: Vec<String> = bucket["transitions"]
.as_object()
.map(|t| t.keys().cloned().collect())
.unwrap_or_default();
for to in bucket["self_moves"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
{
for from in &states {
if legal(from, to) && !moves.iter().any(|x| &x.from == from && x.to == to) {
moves.push(Move { from: from.clone(), label: "their own link".into(), to: to.into(), by: SUBMITTER.into() });
moves.push(Move {
from: from.clone(),
label: "their own link".into(),
to: to.into(),
by: SUBMITTER.into(),
});
}
}
}
@@ -388,7 +449,13 @@ fn route_to(bucket: &Value, from: &str, to: &str) -> Option<Vec<Move>> {
/// dry run walks, so that each button of each role is pressed at least
/// once against the real engine.
pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec<Vec<Move>> {
fn walk(state: &str, done: &[&str], moves: &[Move], path: &mut Vec<Move>, out: &mut Vec<Vec<Move>>) {
fn walk(
state: &str,
done: &[&str],
moves: &[Move],
path: &mut Vec<Move>,
out: &mut Vec<Vec<Move>>,
) {
if done.contains(&state) {
out.push(path.clone());
return;
@@ -403,7 +470,13 @@ pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec<Vec<Move>> {
}
}
let mut out = Vec::new();
walk(bucket["initial"].as_str().unwrap_or("open"), done, &all_moves(bucket), &mut Vec::new(), &mut out);
walk(
bucket["initial"].as_str().unwrap_or("open"),
done,
&all_moves(bucket),
&mut Vec::new(),
&mut out,
);
out
}
@@ -424,7 +497,11 @@ pub(crate) fn resolve_choice(names: &[&str], raw: &str) -> String {
/// be (`FORMAT=json`, honoured by engines/ollama.sh) and the first
/// object in the reply is taken either way.
pub(crate) fn ask(cmd: &str, prompt: &str, seed: &str) -> anyhow::Result<Value> {
let reply = llm_with(cmd, prompt, &[("FORMAT", "json"), ("SEED", seed), ("TEMPERATURE", "0.7")])?;
let reply = llm_with(
cmd,
prompt,
&[("FORMAT", "json"), ("SEED", seed), ("TEMPERATURE", "0.7")],
)?;
Ok(first_object(&reply).unwrap_or(Value::Null))
}
@@ -462,7 +539,11 @@ fn tally(score: &mut NeedScore, case: &Case) {
score.buttons.entry(button.clone()).or_default().0 += 1;
}
for a in &case.ask_backs {
score.buttons.entry(format!("{} -> {}", a.state, a.button)).or_default().1 += 1;
score
.buttons
.entry(format!("{} -> {}", a.state, a.button))
.or_default()
.1 += 1;
}
match &case.outcome {
Outcome::Finished(_) => score.finished += 1,
@@ -476,7 +557,9 @@ fn tally(score: &mut NeedScore, case: &Case) {
}
Outcome::Abandoned(why) => {
score.abandoned += 1;
score.abandon_reasons.push(format!("{}: {why}", case.persona));
score
.abandon_reasons
.push(format!("{}: {why}", case.persona));
}
}
}
@@ -501,7 +584,15 @@ fn per_case(n: usize, s: &NeedScore) -> String {
fn print_scores(scores: &BTreeMap<String, NeedScore>) {
println!(
"\n{:<28} {:>5} {:>9} {:>9} {:>10} {:>9} {:>8} {:>10} {:>13}",
"need", "cases", "finished", "misroute", "abandoned", "no route", "touches", "ask-backs", "unanswerable"
"need",
"cases",
"finished",
"misroute",
"abandoned",
"no route",
"touches",
"ask-backs",
"unanswerable"
);
for (need, s) in scores {
println!(
@@ -529,7 +620,11 @@ fn print_scores(scores: &BTreeMap<String, NeedScore>) {
" {need} [{} / {}]{} {}",
a.state,
a.button,
if a.answerable { "" } else { " (visitor did not know)" },
if a.answerable {
""
} else {
" (visitor did not know)"
},
a.question
);
}
@@ -550,10 +645,16 @@ fn print_scores(scores: &BTreeMap<String, NeedScore>) {
pub fn compare(argv: &[String]) -> anyhow::Result<()> {
let (a, b) = match (argv.get(1), argv.get(2)) {
(Some(a), Some(b)) => (a, b),
_ => return Err(anyhow!("usage: portal-trainer compare <a/score.json> <b/score.json>")),
_ => {
return Err(anyhow!(
"usage: portal-trainer compare <a/score.json> <b/score.json>"
))
}
};
let load = |p: &String| -> anyhow::Result<BTreeMap<String, NeedScore>> {
Ok(serde_json::from_str(&std::fs::read_to_string(p).with_context(|| format!("reading {p}"))?)?)
Ok(serde_json::from_str(
&std::fs::read_to_string(p).with_context(|| format!("reading {p}"))?,
)?)
};
let (a, b) = (load(a)?, load(b)?);
println!(
@@ -565,11 +666,27 @@ pub fn compare(argv: &[String]) -> anyhow::Result<()> {
println!(
"{:<28} {:>14} {:>14} {:>14} {:>16} {:>16}",
need,
format!("{} -> {}", pct(sa.misrouted, sa.cases), pct(sb.misrouted, sb.cases)),
format!("{} -> {}", pct(sa.abandoned, sa.cases), pct(sb.abandoned, sb.cases)),
format!(
"{} -> {}",
pct(sa.misrouted, sa.cases),
pct(sb.misrouted, sb.cases)
),
format!(
"{} -> {}",
pct(sa.abandoned, sa.cases),
pct(sb.abandoned, sb.cases)
),
format!("{} -> {}", pct(sa.stuck, sa.cases), pct(sb.stuck, sb.cases)),
format!("{} -> {}", per_case(sa.touches, sa), per_case(sb.touches, sb)),
format!("{} -> {}", per_case(sa.ask_backs, sa), per_case(sb.ask_backs, sb)),
format!(
"{} -> {}",
per_case(sa.touches, sa),
per_case(sb.touches, sb)
),
format!(
"{} -> {}",
per_case(sa.ask_backs, sa),
per_case(sb.ask_backs, sb)
),
);
}
Ok(())
@@ -589,8 +706,15 @@ mod tests {
#[test]
fn a_choice_is_matched_by_its_heading_however_the_model_dresses_it() {
let names = ["Ask about working", "Ask about working with us", "Get a price"];
assert_eq!(resolve_choice(&names, "Ask about working with us: For carpenters who..."), "Ask about working with us");
let names = [
"Ask about working",
"Ask about working with us",
"Get a price",
];
assert_eq!(
resolve_choice(&names, "Ask about working with us: For carpenters who..."),
"Ask about working with us"
);
assert_eq!(resolve_choice(&names, " get a PRICE "), "Get a price");
assert_eq!(resolve_choice(&names, "Something else"), "Something else");
}
@@ -620,7 +744,10 @@ mod tests {
// Losing a quoted job is the other group's button, so the way
// from open to that ending is a relay: two roles, one case.
let route = route_to(&bucket(), "surveyed", "lost").unwrap();
let relay: Vec<(&str, &str)> = route.iter().map(|m| (m.label.as_str(), m.by.as_str())).collect();
let relay: Vec<(&str, &str)> = route
.iter()
.map(|m| (m.label.as_str(), m.by.as_str()))
.collect();
assert_eq!(relay, [("Quote sent", "pm"), ("Theirs", "other")]);
assert_eq!(route_to(&bucket(), "open", "open").unwrap().len(), 0);
assert!(route_to(&bucket(), "won", "open").is_none());
@@ -630,7 +757,10 @@ mod tests {
fn the_submitters_own_link_is_a_move_like_any_other() {
let mut b = bucket();
b["self_moves"] = json!(["lost"]);
let theirs: Vec<Move> = all_moves(&b).into_iter().filter(|m| m.by == SUBMITTER).collect();
let theirs: Vec<Move> = all_moves(&b)
.into_iter()
.filter(|m| m.by == SUBMITTER)
.collect();
// Only where the machine allows it and no desk already holds it:
// open->lost is the project manager's and quoted->lost the other
// group's, which leaves surveyed->lost to the link.
@@ -641,7 +771,10 @@ mod tests {
#[test]
fn a_dry_run_walks_every_route_to_every_ending() {
let routes = all_routes(&bucket(), &["won", "lost"]);
let ends: Vec<String> = routes.iter().map(|r| format!("{}:{}", r.len(), r.last().unwrap().to)).collect();
let ends: Vec<String> = routes
.iter()
.map(|r| format!("{}:{}", r.len(), r.last().unwrap().to))
.collect();
// open->lost; open->surveyed->quoted->won; and the relay
// open->surveyed->quoted->lost, closed by the other group.
assert_eq!(ends.len(), 3, "{ends:?}");
@@ -664,15 +797,28 @@ mod tests {
moved_by: vec!["pm".into(), "pm".into()],
touches: 3,
ask_backs: vec![
AskBack { state: "open".into(), button: "Looked".into(), question: "Which floor?".into(), answerable: true },
AskBack { state: "surveyed".into(), button: "Quote sent".into(), question: "Load-bearing?".into(), answerable: false },
AskBack {
state: "open".into(),
button: "Looked".into(),
question: "Which floor?".into(),
answerable: true,
},
AskBack {
state: "surveyed".into(),
button: "Quote sent".into(),
question: "Load-bearing?".into(),
answerable: false,
},
],
outcome,
};
tally(&mut score, &base(Outcome::Finished("won".into())));
tally(&mut score, &base(Outcome::Stuck("no route".into())));
tally(&mut score, &base(Outcome::Misrouted("Wrong door".into())));
assert_eq!((score.cases, score.finished, score.stuck, score.misrouted), (3, 1, 1, 1));
assert_eq!(
(score.cases, score.finished, score.stuck, score.misrouted),
(3, 1, 1, 1)
);
assert_eq!((score.ask_backs, score.unanswerable), (6, 3));
}
}
+43 -11
View File
@@ -130,13 +130,18 @@ pub async fn upload(
}
}
let (filename, content_type, bytes) =
file.ok_or((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()));
return Err((
StatusCode::FORBIDDEN,
"not authorized to upload here".to_string(),
));
}
let key = format!("{}-{filename}", uuid::Uuid::new_v4());
@@ -235,7 +240,10 @@ alternatives:
#[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()));
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.
@@ -246,7 +254,10 @@ alternatives:
#[test]
fn a_closed_alternative_takes_nothing() {
let qs = questions(&PAGE.replace(" record_as: enquiries\n", " record_as: enquiries\n disabled: true\n"));
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);
}
}
@@ -277,15 +288,24 @@ pub async fn attachment(
// cannot be used to learn which buckets, records or fields exist.
let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
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 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 as_listed: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| missing())?;
// Through a list the caller may read, and one that shows this
// record: a list of "my cases" opens my files and nobody else's.
if !may_read_record(&state, &session, &bucket, &as_listed).await {
return Err(missing());
}
let answer: crate::answers::Answer = serde_json::from_value(as_listed).map_err(|_| missing())?;
let answer: crate::answers::Answer =
serde_json::from_value(as_listed).map_err(|_| missing())?;
let key = answer
.responses
.get(&field)
@@ -310,13 +330,22 @@ pub async fn attachment(
.body
.collect()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("reading the file failed: {e}")))?
.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(['"', '\\'], "");
let filename = filename
.rsplit('/')
.next()
.unwrap_or(filename)
.replace(['"', '\\'], "");
Ok((
[
(axum::http::header::CONTENT_TYPE, content_type),
@@ -349,7 +378,10 @@ async fn may_read_record(
question.alternatives.iter().any(|alternative| {
alternative.features.iter().any(|feature| {
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket)
&& feature.resource.as_ref().is_some_and(|r| crate::resource::matches_filter(r, record, &params))
&& feature
.resource
.as_ref()
.is_some_and(|r| crate::resource::matches_filter(r, record, &params))
&& access.allows(
&principal,
crate::access::Action::Read,
+25
View File
@@ -1280,6 +1280,31 @@ textarea:focus {
gap: 0.8rem;
}
/* After the newest finished cases: the older ones, on request. Quiet,
since the desk is for what is still moving. */
.show-older {
justify-self: start;
background: none;
border: 0.06rem solid var(--line);
border-radius: 0.4rem;
color: var(--ink-dim);
font: inherit;
font-size: 0.9rem;
padding: 0.35rem 0.8rem;
cursor: pointer;
}
/* A releases manifest: the pick for this device, the rest folded. */
.releases .pill { display: inline-block; }
.releases pre { white-space: pre-wrap; font-size: 0.85rem; margin: 0.4rem 0 0; }
.releases-all summary { color: var(--ink-dim); font-size: 0.9rem; cursor: pointer; }
.releases-all ul { margin: 0.4rem 0 0 1rem; font-size: 0.9rem; }
.show-older:hover {
color: inherit;
border-color: var(--ink-dim);
}
.answer-row {
border: 0.06rem solid var(--line);
border-radius: 0.8rem;
+223 -45
View File
@@ -34,27 +34,51 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
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, 0)
.await
.expect("store");
store_answer(
&js,
&schemas,
&bucket,
"rec-1".into(),
"/",
"Hire us",
&json!({"email": "a@b.example"}),
now,
0,
)
.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();
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");
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:?}");
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:?}");
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.
@@ -67,7 +91,8 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
// 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 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,
@@ -81,13 +106,20 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
};
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");
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();
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");
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.
@@ -122,17 +154,39 @@ async fn a_session_survives_the_process_that_made_it() {
// 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");
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");
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"),
next_boot
.load(&record.id)
.await
.expect("load")
.expect("original intact")
.data
.get("who"),
Some(&json!("ingrid")),
"the original session must be untouched"
);
@@ -146,11 +200,19 @@ async fn a_session_survives_the_process_that_made_it() {
- 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());
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());
assert!(next_boot
.load(&record.id)
.await
.expect("load after delete")
.is_none());
next_boot.delete(&collide.id).await.ok();
}
@@ -175,12 +237,20 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
let questions = std::collections::HashMap::new();
let access = portal::access::Policy::from_content(&questions, &aggregates);
let state = portal::server::AppState {
leptos_options: leptos::config::LeptosOptions::builder().output_name("test").build(),
leptos_options: leptos::config::LeptosOptions::builder()
.output_name("test")
.build(),
nats,
jetstream: js,
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(questions))),
aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(aggregates))),
site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(Default::default()))),
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
portal::content::Storylines::one(questions),
)),
aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
portal::content::Storylines::one(aggregates),
)),
site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
portal::content::Storylines::one(Default::default()),
)),
access: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(access)),
gitea_base: String::new(),
content_raw_base: String::new(),
@@ -198,17 +268,37 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
// A desk invites Kari by phone number; a decision grants her a
// second group by address. Both are invitations until she is seen.
let by_phone = Identity { sub: None, phone: Some("+4791234567".into()), email: None };
let by_mail = Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) };
let first = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant");
let by_phone = Identity {
sub: None,
phone: Some("+4791234567".into()),
email: None,
};
let by_mail = Identity {
sub: None,
phone: None,
email: Some("kari@vel.no".into()),
};
let first = memberships::grant(&state, styret, "Kari", &by_phone, "desk")
.await
.expect("grant");
assert!(first.created && first.reset_link.is_none());
let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant again");
assert!(!again.created, "a second invite into the same group finds the first");
memberships::grant(&state, medlemmer, "Kari", &by_mail, "husstander:abc").await.expect("grant by mail");
let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk")
.await
.expect("grant again");
assert!(
!again.created,
"a second invite into the same group finds the first"
);
memberships::grant(&state, medlemmer, "Kari", &by_mail, "husstander:abc")
.await
.expect("grant by mail");
let records = memberships::all(&state).await;
assert_eq!(records.len(), 2);
assert!(records.iter().all(|r| r.state == INVITED));
assert!(memberships::groups_of(&records, &by_phone).is_empty(), "an invitation is not yet a group");
assert!(
memberships::groups_of(&records, &by_phone).is_empty(),
"an invitation is not yet a group"
);
// She signs in: the provider knows her subject, phone and address.
let kari = portal::auth::User {
@@ -233,18 +323,47 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
}
// A desk takes her out of styret; the record stays, as removed.
assert!(memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke"));
assert!(!memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke twice"), "already out");
assert!(memberships::revoke(&state, styret, &by_phone, "ola")
.await
.expect("revoke"));
assert!(
!memberships::revoke(&state, styret, &by_phone, "ola")
.await
.expect("revoke twice"),
"already out"
);
let records = memberships::all(&state).await;
let styret_record = records.iter().find(|r| memberships::group_of(r) == styret).unwrap();
let styret_record = records
.iter()
.find(|r| memberships::group_of(r) == styret)
.unwrap();
assert_eq!(styret_record.state, REMOVED);
assert_eq!(memberships::groups_of(&records, &Identity { sub: Some("vipps-abc".into()), phone: None, email: None }), vec![medlemmer]);
assert_eq!(
memberships::groups_of(
&records,
&Identity {
sub: Some("vipps-abc".into()),
phone: None,
email: None
}
),
vec![medlemmer]
);
// Invited again: the same record, invited, and hers at the next sign-in.
let back = memberships::grant(&state, styret, "Kari", &by_mail, "desk").await.expect("re-grant");
let back = memberships::grant(&state, styret, "Kari", &by_mail, "desk")
.await
.expect("re-grant");
assert!(back.created);
let records = memberships::all(&state).await;
assert_eq!(records.iter().find(|r| memberships::group_of(r) == styret).unwrap().state, INVITED);
assert_eq!(
records
.iter()
.find(|r| memberships::group_of(r) == styret)
.unwrap()
.state,
INVITED
);
let groups = memberships::on_signed_in(&state, &kari).await;
assert!(groups.contains(&styret.to_string()));
}
@@ -265,15 +384,74 @@ async fn a_card_of_ones_own_is_replaced_not_added_to_even_on_a_double_click() {
// Kari's first card, then two sends of her second pressed at once,
// and Ola's card beside them.
store_answer(&js, &schemas, &bucket, "k1".into(), "/kontor/ansatt", "a", &card("Kari", "kari"), now, 0).await.unwrap();
store_answer(&js, &schemas, &bucket, "o1".into(), "/kontor/ansatt", "a", &card("Ola", "ola"), now + 1, 0).await.unwrap();
store_answer(&js, &schemas, &bucket, "k2".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap();
store_answer(&js, &schemas, &bucket, "k3".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap();
store_answer(
&js,
&schemas,
&bucket,
"k1".into(),
"/kontor/ansatt",
"a",
&card("Kari", "kari"),
now,
0,
)
.await
.unwrap();
store_answer(
&js,
&schemas,
&bucket,
"o1".into(),
"/kontor/ansatt",
"a",
&card("Ola", "ola"),
now + 1,
0,
)
.await
.unwrap();
store_answer(
&js,
&schemas,
&bucket,
"k2".into(),
"/kontor/ansatt",
"a",
&card("Kari N.", "kari"),
now + 2,
0,
)
.await
.unwrap();
store_answer(
&js,
&schemas,
&bucket,
"k3".into(),
"/kontor/ansatt",
"a",
&card("Kari N.", "kari"),
now + 2,
0,
)
.await
.unwrap();
// Both racing sends settle after storing; both must keep the same one.
let (a, b) = tokio::join!(keep_newest_own(&js, &bucket, "open", "kari"), keep_newest_own(&js, &bucket, "open", "kari"));
let (a, b) = tokio::join!(
keep_newest_own(&js, &bucket, "open", "kari"),
keep_newest_own(&js, &bucket, "open", "kari")
);
let _ = (a, b);
let kari = own_records(&js, &bucket, "kari").await;
assert_eq!(kari.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(), vec!["k3"], "one card, the newest");
assert_eq!(own_records(&js, &bucket, "ola").await.len(), 1, "nobody else's card is touched");
assert_eq!(
kari.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(),
vec!["k3"],
"one card, the newest"
);
assert_eq!(
own_records(&js, &bucket, "ola").await.len(),
1,
"nobody else's card is touched"
);
}