Compare commits
4
Commits
v0.5.0
...
down-the-tree
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2596bc3cf | ||
|
|
f0c43c5c0f | ||
|
|
45240b7cab | ||
|
|
6ee6551400 |
+19
-7
@@ -4,6 +4,18 @@ 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).
|
||||
|
||||
## Unreleased
|
||||
|
||||
- A way down the tree from every page: an arrow fixed at the left edge, half-way up, and the same link in words at the foot ("Tilbake til starten", "Back to “What came in?”"). It goes to the nearest page below the current one that the visitor may open, taking the path apart a segment at a time, and to the front door when none is; the front door has none. Content no longer needs a "back" alternative, and a visitor who went up a branch that turned out not to apply is never stranded.
|
||||
|
||||
## 0.5.2 (2026-09-28)
|
||||
|
||||
- The rate limiter answered 500 to any request that arrived without `X-Forwarded-For`. It reads the caller from that header or `X-Real-IP` and falls back to the socket, and 0.5.0 served the app without connect info, so there was no socket to fall back to and no key to rate-limit by. Through Caddy every request carries the header and the site was fine; a health check on localhost got a 500, which is how the first deploy of 0.5.1 failed.
|
||||
|
||||
## 0.5.1 (2026-09-28)
|
||||
|
||||
- A `self_transition` that names no `from` means the bucket's own initial state, not the word `open`. 0.5.0 read the literal, which is only right for a bucket whose records start there: redoal's watchers arrive at `pending` and confirm themselves into `open`, so the rule compiled as `open -> open` and the confirm link in the mail could never match. Wrong the same way before 0.5.0, and now visible because the lint checks the edge exists.
|
||||
|
||||
## 0.5.0 (2026-09-28)
|
||||
|
||||
- Sessions live in NATS KV (`src/sessions.rs`), not in memory. Every restart used to sign out every desk on the site being deployed; they now survive it, in the JetStream that is already under everything else - no new service and no new store to run. The bucket's `max_age` collects what nobody returns for, and a record's own expiry is what answers a load.
|
||||
@@ -341,13 +353,13 @@ content repos pin the tag they run (see the end of this file).
|
||||
## What each site runs
|
||||
|
||||
Read from the hosts on 2026-09-28; a rollout is a pin bump in the
|
||||
site's content repo (`PORTAL_RELEASE` in its `deploy.yml`), or a manual
|
||||
tarball deploy on kasse.
|
||||
site's content repo (`PORTAL_RELEASE` in its `deploy.yml`) - on both
|
||||
hosts now, since kasse's runner deploys its own instances.
|
||||
|
||||
| Site | Host | Instance | Version |
|
||||
|---|---|---|---|
|
||||
| uhhm.no | ergo | `app@uhhm-portal` | v0.4.0 |
|
||||
| redoal.com | ergo | `app@redoal-portal` | v0.4.0 |
|
||||
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.4.0 |
|
||||
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.4.0 |
|
||||
| vel.klingenbergbygg.no | kasse | `app@tomtervel-portal` | v0.4.0 |
|
||||
| uhhm.no | ergo | `app@uhhm-portal` | v0.5.2 |
|
||||
| redoal.com | ergo | `app@redoal-portal` | v0.5.2 |
|
||||
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.5.2 |
|
||||
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.5.2 |
|
||||
| vel.klingenbergbygg.no | kasse | `app@tomtervel-portal` | v0.5.2 |
|
||||
|
||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.5.0"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.5.0"
|
||||
version = "0.5.2"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -124,3 +124,29 @@ garage bucket allow --read --write <site>-attachments --key <site>-portal-upload
|
||||
then `GARAGE_S3_ENDPOINT`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY` and
|
||||
`GARAGE_UPLOADS_BUCKET` in that instance's env. Unset, an upload field
|
||||
fails closed rather than appearing to work and storing nowhere.
|
||||
|
||||
## Who deploys what
|
||||
|
||||
Both hosts deploy from CI now, and a rollout is the same commit either
|
||||
way: bump `PORTAL_RELEASE` in the site's own content repo.
|
||||
|
||||
| | runner | how it is allowed to |
|
||||
|---|---|---|
|
||||
| ergo | `runs-on: bare` | the runner is privileged and writes `/srv/app` and `/etc/app` itself |
|
||||
| kasse | `runs-on: fish` | the runner is not privileged. It may run one root script, `/usr/local/bin/deploy-portal-instance`, which checks the instance against a fixed list and the tag against a release-tag shape before touching anything |
|
||||
|
||||
The kasse grant is a single sudoers line
|
||||
(`/etc/sudoers.d/30-portal-deploy`). It exists so a workflow can pick
|
||||
which of three known instances gets which published portal tag, and
|
||||
nothing else.
|
||||
|
||||
Both paths health-check the instance afterwards **without** a forwarded
|
||||
header. A check that sends the header Caddy would send cannot tell you
|
||||
the site is broken for everything that is not Caddy, which is how
|
||||
v0.5.1 reached a host.
|
||||
|
||||
`IRIS_RELEASE` in a repo's lint workflow should equal its
|
||||
`PORTAL_RELEASE`. Bump it only once iris's own publish job has attached
|
||||
the binary for that tag - portal's release tags iris, but iris builds
|
||||
and uploads on its own run, and a pin bumped in between fails the lint
|
||||
on a download that is not there yet.
|
||||
|
||||
+37
-2
@@ -223,8 +223,15 @@ impl Policy {
|
||||
if let Some(st) = &alt.self_transition {
|
||||
// One rule per state the move may be made from: a
|
||||
// person stays in dialogue with their case for as
|
||||
// long as it runs, and `from` says how long that is.
|
||||
for from in &st.from {
|
||||
// long as it runs, and `from` says how long that
|
||||
// is. Saying nothing means the bucket's own
|
||||
// initial state - the bucket's, not the word
|
||||
// `open`, since a bucket may start anywhere.
|
||||
let initial = aggregates
|
||||
.get(&st.bucket)
|
||||
.map(|s| s.initial.clone())
|
||||
.unwrap_or_else(|| crate::answers::OPEN_STATE.to_string());
|
||||
for from in &st.from_states(&initial) {
|
||||
emit(
|
||||
format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition",
|
||||
format!("bucket {}", st.bucket), format!("{from} -> {}", st.to),
|
||||
@@ -577,6 +584,34 @@ mod tests {
|
||||
assert_eq!(p.rules.iter().filter(|r| r.id.starts_with("self:subscribers:")).count(), 1);
|
||||
}
|
||||
|
||||
// "On arrival" is the bucket's own first state, not the word
|
||||
// `open`. redoal's watchers arrive at `pending` and confirm
|
||||
// themselves into `open`; reading the literal made that rule
|
||||
// `open -> open`, which the engine declares nowhere, so the
|
||||
// confirm link in the mail could never have worked.
|
||||
#[test]
|
||||
fn arrival_is_wherever_the_bucket_actually_starts() {
|
||||
let files = vec![(
|
||||
"index.yaml".to_string(),
|
||||
"name: Home\nalternatives:\n - name: Ask\n record_as: watchers\n - name: Yes, watch it\n self_transition: { bucket: watchers, to: open, label: Yes }\n".to_string(),
|
||||
)];
|
||||
let questions = build_questions(&files).unwrap();
|
||||
let aggregates = parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: watchers\n initial: pending\n states:\n pending: { event: requested }\n open: { event: confirmed }\n transitions:\n pending: [open]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let p = Policy::from_content(&questions, &aggregates);
|
||||
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
||||
assert!(
|
||||
p.rules.iter().any(|r| r.id == "self:watchers:pending>open"),
|
||||
"the rule must start where the records do: {:?}",
|
||||
p.rules.iter().map(|r| &r.id).collect::<Vec<_>>()
|
||||
);
|
||||
let me = Principal::Submitter { record: "r".into(), email: "a@b.c".into() };
|
||||
let bucket = Resource::Bucket { name: "watchers".into() };
|
||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "pending", "to": "open", "stored_email": "a@b.c" })).allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invites_and_automation() {
|
||||
let p = site();
|
||||
|
||||
+7
-1
@@ -376,7 +376,13 @@ pub async fn self_transition_answer(
|
||||
// and the answer must not confirm that the record exists. If it
|
||||
// is not, the case has moved past this move and saying so is no
|
||||
// longer a way to learn anything about it.
|
||||
let in_reach = self_transition.from.iter().any(|s| s == &answer.state);
|
||||
let initial = state
|
||||
.aggregates
|
||||
.load()
|
||||
.get(&self_transition.bucket)
|
||||
.map(|s| s.initial.clone())
|
||||
.unwrap_or_else(|| OPEN_STATE.to_string());
|
||||
let in_reach = self_transition.from_states(&initial).iter().any(|s| s == &answer.state);
|
||||
return Err(ServerFnError::new(if in_reach { "not found" } else { "already processed" }));
|
||||
}
|
||||
|
||||
|
||||
+103
@@ -470,10 +470,83 @@ fn QuestionView(
|
||||
/// Every other question the visitor currently qualifies for, as a
|
||||
/// small nav - how a concern reaches the people it speaks to without
|
||||
/// claiming front-page space (an owner also sees the gated desks here).
|
||||
/// Where the arrow on the left takes a visitor: the nearest page below
|
||||
/// the current one in the tree that they may open, found by taking the
|
||||
/// path apart one segment at a time (`/a/b/c`, then `/a/b`, then `/a`),
|
||||
/// and the front door when none of those is a page. `None` on the front
|
||||
/// door itself, which has nowhere further down to go. It is always a
|
||||
/// valid way on, so a visitor who went up a branch whose context turned
|
||||
/// out not to apply is never left stranded.
|
||||
pub fn down_the_tree(path: &str, pages: &[(String, String)]) -> Option<(String, String)> {
|
||||
let path = path.split(['?', '#']).next().unwrap_or("/").trim_end_matches('/');
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut at = path;
|
||||
while let Some(i) = at.rfind('/') {
|
||||
at = &at[..i];
|
||||
let candidate = if at.is_empty() { "/" } else { at };
|
||||
if let Some(page) = pages.iter().find(|(id, _)| id == candidate) {
|
||||
return Some(page.clone());
|
||||
}
|
||||
if at.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(("/".to_string(), String::new()))
|
||||
}
|
||||
|
||||
/// How the way down names where it goes: "the start" for the front
|
||||
/// door, and otherwise the page's own question, quoted - a page's
|
||||
/// name is the question it asks, which reads as a destination only in
|
||||
/// quotation marks.
|
||||
fn down_the_tree_label(lang: &str, href: &str, name: &str) -> String {
|
||||
let to = if href == "/" || name.is_empty() {
|
||||
t(lang, "the_start").to_string()
|
||||
} else if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") {
|
||||
format!("«{name}»")
|
||||
} else {
|
||||
format!("\u{201c}{name}\u{201d}")
|
||||
};
|
||||
format!("{}{to}", t(lang, "down_to"))
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn QuestionNav(current_id: String) -> impl IntoView {
|
||||
let NavRes(nav) = expect_context();
|
||||
let location = leptos_router::hooks::use_location();
|
||||
view! {
|
||||
// The way down the tree: fixed at the left edge, half-way up the
|
||||
// screen, small enough never to cover what a visitor is reading.
|
||||
// It replaces a "back" alternative on every page that had one.
|
||||
<Suspense fallback=|| ()>
|
||||
{move || {
|
||||
let path = location.pathname.get();
|
||||
nav.get().and_then(|res| res.ok()).and_then(|items| down_the_tree(&path, &items)).map(|(href, name)| {
|
||||
let label = down_the_tree_label(&lang().get(), &href, &name);
|
||||
view! {
|
||||
<a class="down-the-tree" href=href aria-label=label.clone() title=label>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5 8 12l7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</a>
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
// The same way down, at the foot of the page, in words: where a
|
||||
// reader who has read to the end looks for where to go next.
|
||||
<Suspense fallback=|| ()>
|
||||
{move || {
|
||||
let path = location.pathname.get();
|
||||
nav.get().and_then(|res| res.ok()).and_then(|items| down_the_tree(&path, &items)).map(|(href, name)| {
|
||||
let label = down_the_tree_label(&lang().get(), &href, &name);
|
||||
view! {
|
||||
<nav class="down-the-tree-foot">
|
||||
<a href=href>{format!("← {label}")}</a>
|
||||
</nav>
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
<Suspense fallback=|| ()>
|
||||
{move || {
|
||||
let current_id = current_id.clone();
|
||||
@@ -2578,3 +2651,33 @@ async fn upload_file(
|
||||
.as_string()
|
||||
.ok_or_else(|| JsValue::from_str("upload response had no key"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod down_the_tree_tests {
|
||||
use super::down_the_tree;
|
||||
|
||||
fn pages(ids: &[&str]) -> Vec<(String, String)> {
|
||||
ids.iter().map(|id| (id.to_string(), format!("page {id}"))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_way_down_is_the_nearest_page_below_and_never_a_dead_end() {
|
||||
let p = pages(&["/", "/melde-fra", "/review"]);
|
||||
// One level down, to a page that exists.
|
||||
assert_eq!(down_the_tree("/melde-fra", &p).unwrap().0, "/");
|
||||
assert_eq!(down_the_tree("/review/abc123", &p).unwrap().0, "/review");
|
||||
// Segments that are no page of their own are skipped.
|
||||
assert_eq!(down_the_tree("/status/oppslag", &p).unwrap().0, "/");
|
||||
assert_eq!(down_the_tree("/a/b/c/d", &p).unwrap().0, "/");
|
||||
// A trailing slash or a query does not count as a level.
|
||||
assert_eq!(down_the_tree("/review/abc/?chain=x", &p).unwrap().0, "/review");
|
||||
// Named: the start, or the page's question in quotation marks.
|
||||
assert_eq!(super::down_the_tree_label("no", "/", "Hva gjør vi?"), "Tilbake til starten");
|
||||
assert_eq!(super::down_the_tree_label("no", "/review", "Hva kom inn?"), "Tilbake til «Hva kom inn?»");
|
||||
assert_eq!(super::down_the_tree_label("en", "/review", "What came in?"), "Back to \u{201c}What came in?\u{201d}");
|
||||
// The front door has nowhere further down.
|
||||
assert!(down_the_tree("/", &p).is_none());
|
||||
// A visitor who may not open the front door still gets "/".
|
||||
assert_eq!(down_the_tree("/melde-fra", &pages(&["/melde-fra"])).unwrap().0, "/");
|
||||
}
|
||||
}
|
||||
|
||||
+20
-6
@@ -348,15 +348,29 @@ pub struct SelfTransition {
|
||||
/// with a case for as long as it runs, not only on the day they
|
||||
/// sent it: an applicant written to for a missing drawing answers
|
||||
/// from `incomplete`, and may withdraw from anywhere along the
|
||||
/// way. Defaults to the initial state alone, which is what every
|
||||
/// one-shot "unsubscribe" link wants and what this was before it
|
||||
/// could be said.
|
||||
#[serde(default = "initial_state_only")]
|
||||
/// way.
|
||||
///
|
||||
/// Empty means the bucket's own initial state, which is the
|
||||
/// one-shot "confirm" or "unsubscribe" link every one of these was
|
||||
/// before it could say otherwise. Its own initial state, not the
|
||||
/// word `open`: a bucket whose records start at `pending` has a
|
||||
/// confirmation link that fires from `pending`, and reading the
|
||||
/// literal here is what made redoal's watch-confirm button a rule
|
||||
/// the engine would never match.
|
||||
#[serde(default)]
|
||||
pub from: Vec<String>,
|
||||
}
|
||||
|
||||
fn initial_state_only() -> Vec<String> {
|
||||
vec![crate::answers::OPEN_STATE.to_string()]
|
||||
impl SelfTransition {
|
||||
/// The states this move may be made from, with the bucket's
|
||||
/// initial state standing in when the content named none.
|
||||
pub fn from_states(&self, initial: &str) -> Vec<String> {
|
||||
if self.from.is_empty() {
|
||||
vec![initial.to_string()]
|
||||
} else {
|
||||
self.from.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -242,13 +242,7 @@ pub fn validate_questions(
|
||||
question.id, alternative.name, st.to, st.bucket
|
||||
);
|
||||
}
|
||||
if st.from.is_empty() {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: self_transition.from is empty, so the move can never be made",
|
||||
question.id, alternative.name
|
||||
);
|
||||
}
|
||||
for from in &st.from {
|
||||
for from in &st.from_states(&schema.initial) {
|
||||
if !schema.has_state(from) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: self_transition.from {:?} is not a declared state for bucket {:?}",
|
||||
|
||||
@@ -33,6 +33,8 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
"This part of the site is for organizational owners — sign in with that account to take a look."
|
||||
}
|
||||
("en", "announcements") => "Announcements",
|
||||
("en", "down_to") => "Back to ",
|
||||
("en", "the_start") => "the start",
|
||||
|
||||
("no", "also_worth_asking") => "Også verdt å spørre",
|
||||
("no", "asked_by") => "Stilt av ",
|
||||
@@ -51,6 +53,8 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
"Denne delen av siden er for organisasjonens eiere — logg inn med den kontoen for å ta en titt."
|
||||
}
|
||||
("no", "announcements") => "Kunngjøringer",
|
||||
("no", "down_to") => "Tilbake til ",
|
||||
("no", "the_start") => "starten",
|
||||
|
||||
_ => return None,
|
||||
})
|
||||
@@ -83,6 +87,8 @@ mod tests {
|
||||
"follows_answer",
|
||||
"owners_only",
|
||||
"announcements",
|
||||
"down_to",
|
||||
"the_start",
|
||||
] {
|
||||
assert!(lookup("en", key).is_some(), "en missing {key}");
|
||||
assert!(lookup("no", key).is_some(), "no missing {key}");
|
||||
|
||||
+11
-1
@@ -278,7 +278,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
tracing::info!("listening on http://{addr}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
axum::serve(listener, app.into_make_service()).await?;
|
||||
// With connect info, not without: the rate limiter reads the
|
||||
// caller from `X-Forwarded-For`/`X-Real-IP` and falls back to the
|
||||
// socket, and without this there is no socket to fall back to. A
|
||||
// request that arrives without those headers - a health check on
|
||||
// localhost, anything not through Caddy - then has no key at all,
|
||||
// and the limiter answers 500 to every one of them.
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -297,6 +297,67 @@ main.not-found {
|
||||
}
|
||||
}
|
||||
|
||||
/* The way down the tree: one level toward the front door, from any
|
||||
page. Fixed at the left edge, half-way up, over the margin rather
|
||||
than the text; small, and half-faded until touched, so it never
|
||||
covers what a visitor is reading. */
|
||||
.down-the-tree {
|
||||
position: fixed;
|
||||
left: 0.6rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 20;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg-opaque);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 0.2rem 0.8rem var(--card-shadow);
|
||||
opacity: 0.7;
|
||||
transition: opacity 120ms, transform 120ms;
|
||||
}
|
||||
|
||||
.down-the-tree:hover,
|
||||
.down-the-tree:focus-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(-0.12rem);
|
||||
}
|
||||
|
||||
.down-the-tree svg {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
}
|
||||
|
||||
@media (max-width: 43.75rem) {
|
||||
.down-the-tree {
|
||||
left: 0.25rem;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.down-the-tree-foot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
margin: 2.6rem auto 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.down-the-tree-foot a {
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
border-bottom: 0.06rem solid var(--line);
|
||||
}
|
||||
|
||||
.down-the-tree-foot a:hover {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.question-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user