Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45240b7cab | ||
|
|
6ee6551400 |
@@ -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
|
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).
|
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)
|
## 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.
|
- 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
@@ -3357,7 +3357,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.5.0"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.5.0"
|
version = "0.5.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
+37
-2
@@ -223,8 +223,15 @@ impl Policy {
|
|||||||
if let Some(st) = &alt.self_transition {
|
if let Some(st) = &alt.self_transition {
|
||||||
// One rule per state the move may be made from: a
|
// One rule per state the move may be made from: a
|
||||||
// person stays in dialogue with their case for as
|
// person stays in dialogue with their case for as
|
||||||
// long as it runs, and `from` says how long that is.
|
// long as it runs, and `from` says how long that
|
||||||
for from in &st.from {
|
// 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(
|
emit(
|
||||||
format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition",
|
format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition",
|
||||||
format!("bucket {}", st.bucket), format!("{from} -> {}", st.to),
|
format!("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);
|
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]
|
#[test]
|
||||||
fn invites_and_automation() {
|
fn invites_and_automation() {
|
||||||
let p = site();
|
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
|
// 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
|
// is not, the case has moved past this move and saying so is no
|
||||||
// longer a way to learn anything about it.
|
// 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" }));
|
return Err(ServerFnError::new(if in_reach { "not found" } else { "already processed" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-6
@@ -348,15 +348,29 @@ pub struct SelfTransition {
|
|||||||
/// with a case for as long as it runs, not only on the day they
|
/// 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
|
/// sent it: an applicant written to for a missing drawing answers
|
||||||
/// from `incomplete`, and may withdraw from anywhere along the
|
/// from `incomplete`, and may withdraw from anywhere along the
|
||||||
/// way. Defaults to the initial state alone, which is what every
|
/// way.
|
||||||
/// one-shot "unsubscribe" link wants and what this was before it
|
///
|
||||||
/// could be said.
|
/// Empty means the bucket's own initial state, which is the
|
||||||
#[serde(default = "initial_state_only")]
|
/// 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>,
|
pub from: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn initial_state_only() -> Vec<String> {
|
impl SelfTransition {
|
||||||
vec![crate::answers::OPEN_STATE.to_string()]
|
/// 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)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -242,13 +242,7 @@ pub fn validate_questions(
|
|||||||
question.id, alternative.name, st.to, st.bucket
|
question.id, alternative.name, st.to, st.bucket
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if st.from.is_empty() {
|
for from in &st.from_states(&schema.initial) {
|
||||||
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 {
|
|
||||||
if !schema.has_state(from) {
|
if !schema.has_state(from) {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"question {:?} alternative {:?}: self_transition.from {:?} is not a declared state for bucket {:?}",
|
"question {:?} alternative {:?}: self_transition.from {:?} is not a declared state for bucket {:?}",
|
||||||
|
|||||||
+11
-1
@@ -278,7 +278,17 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
tracing::info!("listening on http://{addr}");
|
tracing::info!("listening on http://{addr}");
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user