Compare commits

...
2 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug 45240b7cab Release 0.5.2
Test / test (push) Successful in 57s
Publish release / publish (push) Successful in 3m1s
2026-09-28 18:09:28 +02:00
Bendik Aagaard Lynghaug 6ee6551400 Release 0.5.1
Test / test (push) Successful in 58s
Publish release / publish (push) Successful in 3m2s
2026-09-28 17:48:37 +02:00
8 changed files with 86 additions and 19 deletions
+8
View File
@@ -4,6 +4,14 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file).
## 0.5.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.
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.5.0"
version = "0.5.2"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.5.0"
version = "0.5.2"
edition = "2021"
[lib]
+37 -2
View File
@@ -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
View File
@@ -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" }));
}
+20 -6
View File
@@ -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)]
+1 -7
View File
@@ -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 {:?}",
+11 -1
View File
@@ -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(())
}