From 9a41641eaae9ab3b4326b01388f02202287f44a9 Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 17 Oct 2026 13:18:03 +0000 Subject: [PATCH] Signals: an admin chooses whether the server's TypeSafe key pays for everyone (#271) With signals_on_server set and TYPESAFE_KEY present, the judge asks Jev about everyone's Signals on the server's key; otherwise each person turns Signals on in Settings with a key of their own, which is now checked with TypeSafe before it is saved. The Signals folder shows only when they are on. The browser suite's daemon inherited the shell's TYPESAFE_KEY, so fixture feeds were sent to the real TypeSafe to be categorised; it is blanked now, and an empty key counts as none. Co-Authored-By: Claude Opus 5.5 --- crates/ipx/CHANGELOG.md | 5 ++ crates/ipx/src/config.rs | 11 +++- crates/ipx/src/db.rs | 23 ++++++--- crates/ipx/src/main.rs | 2 +- crates/ipx/src/signals.rs | 100 ++++++++++++++++++++++++------------- crates/ipx/src/web.rs | 29 ++++++++++- playwright.config.js | 2 + tests/page-unit.js | 9 ++++ tests/ui/app.spec.js | 36 ++++++++++++- tests/ui/fixtures/serve.js | 8 ++- web/CHANGELOG.md | 8 +++ web/src/admin.ts | 9 +++- web/src/dialogs.ts | 56 ++++++++++++++------- web/src/signals.ts | 13 +++-- web/src/types.ts | 2 + 15 files changed, 242 insertions(+), 71 deletions(-) diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 313bb67..4c3f8d5 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `/metrics` counts Signals: items judged and fitting, calls to Jev by how they ended and the time they took, time since the judge's last pass, and keys refused; a failed call to Jev shows as a failed trace (#270). +- An admin setting, Signals for everyone on the server's TypeSafe key, so the server pays for Jev instead of each person (#271). + +### Changed + +- A TypeSafe key is checked with TypeSafe before it is saved, and one it refuses is not saved (#271). ## [1.3.0] - 2026-10-16 diff --git a/crates/ipx/src/config.rs b/crates/ipx/src/config.rs index 0993ca5..e58f388 100644 --- a/crates/ipx/src/config.rs +++ b/crates/ipx/src/config.rs @@ -50,6 +50,9 @@ pub struct General { pub directory_token: String, /// Send the directory the public feeds people here subscribe to, and how many of them do. pub directory_share: bool, + /// Signals are judged on the server's TypeSafe key (`TYPESAFE_KEY`), for everyone, rather than + /// on each person's own (#271). Without that key it changes nothing. + pub signals_on_server: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] @@ -207,6 +210,7 @@ impl Default for General { directory_url: String::new(), directory_token: String::new(), directory_share: false, + signals_on_server: false, } } } @@ -331,6 +335,8 @@ pub struct Stored { pub directory_token: String, #[serde(default)] pub directory_share: bool, + #[serde(default)] + pub signals_on_server: bool, } fn art_cache_mb() -> u64 { @@ -338,7 +344,7 @@ fn art_cache_mb() -> u64 { } /// `[general]` keys that live in the database once it holds the configuration. -const STORED_KEYS: [&str; 9] = [ +const STORED_KEYS: [&str; 10] = [ "schedule", "max_total_gb", "max_age_days", @@ -348,6 +354,7 @@ const STORED_KEYS: [&str; 9] = [ "directory_url", "directory_token", "directory_share", + "signals_on_server", ]; impl Stored { @@ -363,6 +370,7 @@ impl Stored { directory_url: g.directory_url.clone(), directory_token: g.directory_token.clone(), directory_share: g.directory_share, + signals_on_server: g.signals_on_server, } } @@ -377,6 +385,7 @@ impl Stored { g.directory_url = self.directory_url; g.directory_token = self.directory_token; g.directory_share = self.directory_share; + g.signals_on_server = self.signals_on_server; } } diff --git a/crates/ipx/src/db.rs b/crates/ipx/src/db.rs index 372fea9..538e8fe 100644 --- a/crates/ipx/src/db.rs +++ b/crates/ipx/src/db.rs @@ -2579,7 +2579,8 @@ pub struct Ask { #[derive(Debug)] pub struct Unjudged { pub user_id: i64, - pub key: String, + /// Their own key; None when the server's is used for everyone. + pub key: Option, pub feed_id: String, pub guid: String, pub feed_title: Option, @@ -2704,8 +2705,9 @@ impl Db { /// Up to `limit` items, newest first, that someone with a key has a Signal not yet asked /// about: from the feeds they subscribe to, the last `SIGNAL_DAYS`, and not hidden from them. - /// `skip` are people whose key TypeSafe has refused. - pub async fn unjudged(&self, skip: &[i64], limit: i64) -> Result> { + /// `skip` are people whose key TypeSafe has refused. With `everyone`, the server's key is + /// used, and people without a key of their own are asked for too (#271). + pub async fn unjudged(&self, skip: &[i64], limit: i64, everyone: bool) -> Result> { let mut a = Args::default(); let since = a.p(now() - SIGNAL_DAYS * 86_400); let skip = if skip.is_empty() { @@ -2714,6 +2716,7 @@ impl Db { format!("AND g.user_id NOT IN ({})", skip.iter().map(|u| a.p(*u)).collect::>().join(", ")) }; let limit = a.p(limit); + let keyed = if everyone { "true" } else { "u.typesafe_key IS NOT NULL" }; let sql = format!( "SELECT g.id AS signal_id, g.name, g.keywords, g.description AS signal_description, g.user_id, u.typesafe_key, e.feed_id, e.guid, e.title, e.description, f.title AS feed_title @@ -2722,7 +2725,7 @@ impl Db { JOIN subscriptions s ON s.user_id = g.user_id JOIN entries e ON e.feed_id = s.feed_id LEFT JOIN feeds f ON f.id = e.feed_id - WHERE u.typesafe_key IS NOT NULL {skip} + WHERE {keyed} {skip} AND coalesce(e.published, e.first_seen) >= {since} AND NOT EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = g.id AND v.feed_id = e.feed_id AND v.guid = e.guid) @@ -3394,19 +3397,23 @@ mod tests { let food = db.add_signal(1, "Food", &words(&["baking"]), Some("Bread and cakes")).await.unwrap(); db.add_signal(2, "Theirs", &words(&["x"]), None).await.unwrap(); - assert!(db.unjudged(&[], 50).await.unwrap().is_empty(), "nobody has a key yet"); + assert!(db.unjudged(&[], 50, false).await.unwrap().is_empty(), "nobody has a key yet"); + let everyone = db.unjudged(&[], 50, true).await.unwrap(); + let mut seen: Vec<_> = everyone.iter().map(|u| (u.user_id, u.guid.as_str(), u.key.is_none())).collect(); + seen.sort(); + assert_eq!(seen, [(1, "x", true), (1, "y", true), (2, "x", true)], "on the server's key, everyone's Signals, no key of their own needed"); db.set_typesafe_key(1, Some("k")).await.unwrap(); - let queue = db.unjudged(&[], 50).await.unwrap(); + let queue = db.unjudged(&[], 50, false).await.unwrap(); let mut seen: Vec<_> = queue.iter().map(|u| (u.guid.as_str(), u.signals.len())).collect(); seen.sort(); assert_eq!(seen, [("x", 2), ("y", 2)], "each recent subscribed item once, with both Signals; old and unsubscribed left out"); let ask = queue[0].signals.iter().find(|a| a.id == food).unwrap(); assert_eq!((ask.name.as_str(), ask.description.as_deref()), ("Food", Some("Bread and cakes")), "Jev is asked about the name and the sentence"); - assert!(db.unjudged(&[1], 50).await.unwrap().is_empty(), "a refused key is passed over"); + assert!(db.unjudged(&[1], 50, false).await.unwrap().is_empty(), "a refused key is passed over"); db.record_verdicts("f", "x", &[(f1, true), (food, false)]).await.unwrap(); db.record_verdicts("g", "y", &[(f1, false), (food, true)]).await.unwrap(); - assert!(db.unjudged(&[], 50).await.unwrap().is_empty(), "nothing is asked twice"); + assert!(db.unjudged(&[], 50, false).await.unwrap().is_empty(), "nothing is asked twice"); let order = order_sql("published", "desc"); let page = db.signal_entries(1, f1, Filter::All, None, 0, 50, &order).await.unwrap(); diff --git a/crates/ipx/src/main.rs b/crates/ipx/src/main.rs index 4828e8d..f45917a 100644 --- a/crates/ipx/src/main.rs +++ b/crates/ipx/src/main.rs @@ -1837,7 +1837,7 @@ async fn scan_one( && (force || stored.entries == 0) && !parsed.entries.is_empty() && ctx.cfg().feeds.get(id).is_some_and(|f| f.category.is_none()) - && let Ok(key) = std::env::var("TYPESAFE_KEY") + && let Some(key) = signals::typesafe_key() { match feed::classify(&ctx.client, &key, &parsed).await { Ok(category) => { diff --git a/crates/ipx/src/signals.rs b/crates/ipx/src/signals.rs index 1364f3e..eb3207c 100644 --- a/crates/ipx/src/signals.rs +++ b/crates/ipx/src/signals.rs @@ -46,10 +46,27 @@ pub async fn run(ctx: Arc, mut stop: tokio::sync::watch::Receiver) { } } +/// The key every Signal is judged on, when an admin has chosen that the server pays for Jev +/// (#271) and the server has a key; None leaves each person to their own. +pub fn server_key(ctx: &Ctx) -> Option { + if !ctx.cfg().general.signals_on_server { + return None; + } + typesafe_key() +} + +/// The server's own TypeSafe key, `TYPESAFE_KEY`; set but empty is none, as the browser suite +/// sets it so the test daemon never spends a real one. +pub fn typesafe_key() -> Option { + std::env::var("TYPESAFE_KEY").ok().filter(|k| !k.trim().is_empty()) +} + /// One batch. True when there may be more waiting. async fn pass(ctx: &Ctx) -> Result { - let skip: Vec = ctx.signals_refused.lock().unwrap().iter().copied().collect(); - let items = ctx.db.unjudged(&skip, BATCH).await?; + let server = server_key(ctx); + // People's refused keys are not what is asked with when the server's is. + let skip: Vec = if server.is_some() { Vec::new() } else { ctx.signals_refused.lock().unwrap().iter().copied().collect() }; + let items = ctx.db.unjudged(&skip, BATCH, server.is_some()).await?; if items.is_empty() { return Ok(false); } @@ -63,7 +80,8 @@ async fn pass(ctx: &Ctx) -> Result { if refused.contains(&item.user_id) { continue; } - match judge(ctx, item).await { + let Some(key) = server.as_deref().or(item.key.as_deref()) else { continue }; + match judge(ctx, item, key).await { Ok(verdicts) => { judged += 1; let hits = verdicts.iter().filter(|(_, h)| *h).count(); @@ -77,6 +95,12 @@ async fn pass(ctx: &Ctx) -> Result { } // A key TypeSafe will not take is not sent the rest of the queue. It is tried // again once its owner saves one (`web::patch_me`) or the daemon restarts. + // The server's key refused is everyone's: the queue waits for the next pass. + Err(e) if refuses_key(&e) && server.is_some() => { + tracing::warn!(error = %format!("{e:#}"), "TypeSafe refused the server's key for Signals"); + stopped = true; + break; + } Err(e) if refuses_key(&e) => { tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key"); refused.insert(item.user_id); @@ -136,7 +160,7 @@ fn full(items: &[Unjudged]) -> bool { items.iter().map(|i| i.signals.len()).sum::() as i64 >= BATCH } -fn refuses_key(e: &anyhow::Error) -> bool { +pub fn refuses_key(e: &anyhow::Error) -> bool { matches!(status(e), Some(401 | 402 | 403)) } @@ -144,8 +168,7 @@ fn malformed(e: &anyhow::Error) -> bool { matches!(status(e), Some(400 | 422)) } -async fn judge(ctx: &Ctx, item: &Unjudged) -> Result> { - let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned()); +async fn judge(ctx: &Ctx, item: &Unjudged, key: &str) -> Result> { let span = tracing::info_span!( "jev", user = item.user_id, @@ -154,17 +177,7 @@ async fn judge(ctx: &Ctx, item: &Unjudged) -> Result> { error = tracing::field::Empty, "otel.status_code" = tracing::field::Empty, ); - let answer: serde_json::Value = called(span.clone(), async { - let r = jev_post(ctx, url, &item.key).json(&request(item)).send().await?; - let status = r.status().as_u16(); - tracing::Span::current().record("http.response.status_code", status); - if !r.status().is_success() { - let body: String = r.text().await.unwrap_or_default().chars().take(500).collect(); - return Err(Refused { status, body }.into()); - } - anyhow::Ok(r.json().await?) - }) - .await?; + let answer = called(span, ask(ctx, key, &request(item))).await?; // A question Jev left unanswered stays unjudged and is asked again. Ok(item.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect()) } @@ -217,7 +230,6 @@ fn hit(answer: &serde_json::Value, id: i64) -> Option { /// ipx-web's, and sends the likeliest. On the server's key, not the Signal owner's: it is the /// server's page doing it, once a Signal. pub async fn pick_icon(ctx: &Ctx, key: &str, name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> Result { - let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned()); let span = tracing::info_span!( "jev_icon", candidates = candidates.len(), @@ -226,26 +238,46 @@ pub async fn pick_icon(ctx: &Ctx, key: &str, name: &str, words: &[String], descr "otel.status_code" = tracing::field::Empty, ); called(span, async { - let r = jev_post(ctx, url, key).json(&icon_request(name, words, description, candidates)).send().await?; - let status = r.status().as_u16(); - tracing::Span::current().record("http.response.status_code", status); - if !r.status().is_success() { - let body: String = r.text().await.unwrap_or_default().chars().take(500).collect(); - return Err(Refused { status, body }.into()); - } - let answer: serde_json::Value = r.json().await?; + let answer = ask(ctx, key, &icon_request(name, words, description, candidates)).await?; chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}")) }) .await } -/// A request to Jev, carrying this trace's `traceparent` as every call out of ipx does. -fn jev_post(ctx: &Ctx, url: String, key: &str) -> reqwest::RequestBuilder { - let req = ctx.client.post(url).bearer_auth(key).timeout(feed::FEED_TIMEOUT); - match ipx_directory::otel::traceparent() { - Some(tp) => req.header("traceparent", tp), - None => req, +/// Whether TypeSafe takes `key`, by one short question, before it is saved (#271): a key it +/// refuses would sit saved with nothing ever picked out, and nobody told why. +pub async fn check_key(ctx: &Ctx, key: &str) -> Result<()> { + let span = tracing::info_span!( + "jev_key_check", + "http.response.status_code" = tracing::field::Empty, + error = tracing::field::Empty, + "otel.status_code" = tracing::field::Empty, + ); + let body = serde_json::json!({ + "model": "jev-latest", + "state": "A reader is saving their key.", + "questions": { "ok": { "type": "noul", "instructions": "Is this a key check?", + "criteria": { "true": "It is.", "false": "It is not." } } }, + }); + called(span, ask(ctx, key, &body)).await.map(|_| ()) +} + +/// Puts `body` to Jev on `key` and gives its answer, or what TypeSafe refused it with. Carries +/// this trace's `traceparent`, as every call out of ipx does. +async fn ask(ctx: &Ctx, key: &str, body: &serde_json::Value) -> Result { + let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned()); + let mut req = ctx.client.post(url).bearer_auth(key).json(body).timeout(feed::FEED_TIMEOUT); + if let Some(tp) = ipx_directory::otel::traceparent() { + req = req.header("traceparent", tp); } + let r = req.send().await?; + let status = r.status().as_u16(); + tracing::Span::current().record("http.response.status_code", status); + if !r.status().is_success() { + let body: String = r.text().await.unwrap_or_default().chars().take(500).collect(); + return Err(Refused { status, body }.into()); + } + Ok(r.json().await?) } /// Runs a call to Jev in `span`, counting it in /metrics by how it ended, and marking the span @@ -389,7 +421,7 @@ mod tests { #[test] fn a_batch_is_full_by_its_rows_not_its_items() { let item = |n: usize| Unjudged { - user_id: 1, key: "k".into(), feed_id: "f".into(), guid: "g".into(), feed_title: None, title: None, description: None, + user_id: 1, key: Some("k".into()), feed_id: "f".into(), guid: "g".into(), feed_title: None, title: None, description: None, signals: (0..n as i64).map(|id| Ask { id, name: "s".into(), keywords: vec![], description: None }).collect(), }; assert!(full(&(0..25).map(|_| item(2)).collect::>()), "25 items of two Signals each is the 50 asked for"); @@ -400,7 +432,7 @@ mod tests { fn each_signal_is_its_own_question_on_the_items_words() { let item = Unjudged { user_id: 1, - key: "k".into(), + key: Some("k".into()), feed_id: "f".into(), guid: "g".into(), feed_title: Some("Pit Lane".into()), diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index e0096c1..c4a5f4f 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -387,6 +387,8 @@ async fn me( "name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked, "deadair_saved": saved, "theme": theme, "version": env!("CARGO_PKG_VERSION"), "typesafe_key_set": state.ctx.db.typesafe_key_set(user.id).await.unwrap_or(false), + // Whose key Signals are judged on (#271): "server", for everyone, or "own", each person's. + "signals": if crate::signals::server_key(&state.ctx).is_some() { "server" } else { "own" }, })) } @@ -414,6 +416,16 @@ async fn patch_me( if key.as_ref().is_some_and(|k| k.len() > 500 || !k.bytes().all(|b| b.is_ascii_graphic())) { return Err(ApiError::bad_request("that does not look like a TypeSafe key")); } + if let Some(k) = &key { + crate::signals::check_key(&state.ctx, k).await.map_err(|e| { + if crate::signals::refuses_key(&e) { + ApiError::bad_request("TypeSafe did not accept that key") + } else { + tracing::warn!(error = %format!("{e:#}"), "a TypeSafe key could not be checked"); + ApiError::bad_gateway("TypeSafe could not be asked about that key just now; try again") + } + })?; + } state.ctx.db.set_typesafe_key(user.id, key.as_deref()).await?; state.ctx.signals_refused.lock().unwrap().remove(&user.id); state.ctx.signals.notify_one(); @@ -517,7 +529,7 @@ async fn pick_signal_icon( _user: crate::db::User, Json(body): Json, ) -> Result, ApiError> { - let Ok(key) = std::env::var("TYPESAFE_KEY") else { + let Some(key) = crate::signals::typesafe_key() else { return Err(ApiError::not_found("picking an icon is not set up on this server")); }; let name = signal_name(&body.name)?; @@ -2319,6 +2331,9 @@ struct Settings { /// Whether a token is set: the token itself never leaves the server. directory_token_set: bool, directory_share: bool, + signals_on_server: bool, + /// Whether the server has a TypeSafe key (`TYPESAFE_KEY`) for `signals_on_server` to use. + typesafe_server_key: bool, } async fn get_settings(State(state): State) -> Json { @@ -2335,6 +2350,8 @@ async fn get_settings(State(state): State) -> Json { directory_url: cfg.general.directory_url.clone(), directory_token_set: !cfg.general.directory_token.trim().is_empty(), directory_share: cfg.general.directory_share, + signals_on_server: cfg.general.signals_on_server, + typesafe_server_key: crate::signals::typesafe_key().is_some(), }) } @@ -2351,6 +2368,7 @@ struct SettingsPatch { /// Replaces the token; empty clears it. directory_token: Option, directory_share: Option, + signals_on_server: Option, } async fn patch_settings( @@ -2403,7 +2421,16 @@ async fn patch_settings( if let Some(v) = body.directory_share { cfg.general.directory_share = v; } + let signals_changed = body.signals_on_server.is_some_and(|v| v != cfg.general.signals_on_server); + if let Some(v) = body.signals_on_server { + cfg.general.signals_on_server = v; + } state.ctx.store_cfg(cfg).await?; + if signals_changed { + // Whoever's key was refused is tried again on whichever key is now used. + state.ctx.signals_refused.lock().unwrap().clear(); + state.ctx.signals.notify_one(); + } Ok(StatusCode::NO_CONTENT) } diff --git a/playwright.config.js b/playwright.config.js index 583e888..fc1de5b 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -60,6 +60,8 @@ module.exports = defineConfig({ // production's Tempo and never opens production's database. OTEL_EXPORTER_OTLP_ENDPOINT: '', IPX_DATABASE_URL: '', + // Nor spends the server's TypeSafe key: fixture feeds went to TypeSafe to be categorised. + TYPESAFE_KEY: '', IPX_TYPESAFE_URL: 'http://127.0.0.1:8792/jev', }, }, diff --git a/tests/page-unit.js b/tests/page-unit.js index 563b660..25bf1f7 100644 --- a/tests/page-unit.js +++ b/tests/page-unit.js @@ -199,3 +199,12 @@ test('Jev is offered the icons found for each word of a Signal', () => { assert.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']); assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items ยท 1 unread'); }); + +test('Signals are everyone\'s on the server\'s key, and yours on your own once a key is saved', () => { + const on = me => { run(`S.me = ${JSON.stringify(me)}`); return run('signalsOn()'); }; + assert.equal(on({ signals: 'server', typesafe_key_set: false }), true); + assert.equal(on({ signals: 'own', typesafe_key_set: true }), true); + assert.equal(on({ signals: 'own', typesafe_key_set: false }), false); + assert.equal(on(null), false); + run('S.me = null'); +}); diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 6926410..caaf3f4 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1143,7 +1143,8 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async () => page.locator('#content .acts [data-a="settings"]').click(), () => page.locator('#content .acts [data-a="dl"]').click(), () => page.locator('#content .acts [data-a="rm"]').click(), - async () => { await page.locator('#feedlist .sigs').click(); await page.locator('#content .acts [data-a="new"]').click(); }, + // The Signals folder is not there without a key (#271); the dialog is the same either way. + () => page.evaluate(() => signalModal()), ]; for (const open of dialogs) { await open(); @@ -2127,3 +2128,36 @@ test('a Signal with no key to judge it links to where one is got and where it go await page.keyboard.press('Escape'); await page.request.delete(`/api/signals/${id}`); }); + +test('Signals are turned on in Settings with a key TypeSafe takes, and off again by forgetting it', async ({ page }) => { + // #271. The test daemon has no key of its own, so each person brings theirs. + await page.request.patch('/api/me', { data: { typesafe_key: null } }); + await page.reload(); + await expect(page.locator('#feedlist .sigs')).toHaveCount(0); + await page.evaluate(() => prefsModal()); + await expect(page.locator('#ssigkey')).toBeHidden(); + await page.locator('#ssigon').check(); + await page.locator('#stskey').fill('bad-key'); + await page.locator('#stskey').press('Enter'); + await page.locator('#stskey').blur(); + await expect(page.locator('.toast', { hasText: 'TypeSafe did not accept that key' })).toBeVisible(); + expect((await (await page.request.get('/api/me')).json()).typesafe_key_set).toBe(false); + await page.locator('#stskey').fill('test-key'); + await page.locator('#stskey').blur(); + await expect(page.locator('#feedlist .sigs')).toHaveCount(1); + page.once('dialog', d => d.accept()); + await page.locator('#ssigon').uncheck(); + await expect(page.locator('#feedlist .sigs')).toHaveCount(0); + expect((await (await page.request.get('/api/me')).json()).typesafe_key_set).toBe(false); + await page.keyboard.press('Escape'); +}); + +test('the server pays for Signals only when it has a key to pay with', async ({ page }) => { + const settings = await (await page.request.get('/api/settings')).json(); + expect(settings.typesafe_server_key).toBe(false); + expect((await page.request.patch('/api/settings', { data: { signals_on_server: true } })).status()).toBe(204); + expect((await (await page.request.get('/api/me')).json()).signals).toBe('own'); + await page.goto('/admin#server'); + await expect(page.locator('#gsig')).toBeDisabled(); + await page.request.patch('/api/settings', { data: { signals_on_server: false } }); +}); diff --git a/tests/ui/fixtures/serve.js b/tests/ui/fixtures/serve.js index 327f165..6cf14ea 100644 --- a/tests/ui/fixtures/serve.js +++ b/tests/ui/fixtures/serve.js @@ -13,6 +13,11 @@ http.createServer((req, res) => { let body = ''; req.on('data', c => { body += c; }); req.on('end', () => { + // As TypeSafe answers a key it does not know (#271). + if (req.headers.authorization === 'Bearer bad-key') { + res.writeHead(401, { 'content-type': 'application/json' }).end('{"detail":"invalid api key"}'); + return; + } const { state, questions } = JSON.parse(body); if (Object.values(questions).some(q => typeof q.criteria !== 'object')) { res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}'); @@ -21,7 +26,8 @@ http.createServer((req, res) => { const title = String(state).toLowerCase(); const answers = {}; for (const [id, q] of Object.entries(questions)) { - const words = /described by these words: ([^.]*)\./.exec(q.instructions)[1].split(', '); + // A key's check asks about no Signal's words. + const words = /described by these words: ([^.]*)\./.exec(q.instructions)?.[1].split(', ') ?? []; answers[id] = { noul: words.some(w => title.includes(w.toLowerCase())) ? 0.9 : 0.1 }; } res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ answers })); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index e7ca7fa..685b26e 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Admin, Server: a setting to give everyone Signals on the server's TypeSafe key (#271). + +### Changed + +- Unless the server pays for them, Signals are turned on in Settings with your own TypeSafe key, and the Signals folder shows only once they are on (#271). + ## [1.1.0] - 2026-10-16 ### Added diff --git a/web/src/admin.ts b/web/src/admin.ts index e1d64e4..7e6a742 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -53,6 +53,11 @@ async function drawServer(){ Show and episode artwork is kept here once shown, so it loads from iPX instead of each publisher. Over this, what has gone longest unshown is dropped first. +
+ ${g.typesafe_server_key + ? 'On, everyone has Signals and every item read for one is a call on this server\'s key. Off, each person turns Signals on in their own Settings with a key of their own.' + : 'This server has no TypeSafe key (TYPESAFE_KEY), so each person uses their own.'}
${esc(g.download_dir)}
`; @@ -64,7 +69,9 @@ async function drawServer(){ media_types: $('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean), max_total_gb: Number($('#gquota').value) || 0, max_age_days: Number($('#gage').value) || 0, - art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})}); + art_cache_mb: Math.max(0, Number($('#gart').value) || 0), + // Left alone while the server has no key to offer, rather than switched off. + ...($('#gsig').disabled ? {} : {signals_on_server: $('#gsig').checked})})}); toast('Settings saved'); }catch(e){ toast(why(e), true); } }; diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 2f7edec..473f90d 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -440,13 +440,18 @@ async function prefsModal(){ Comma separated words or phrases, in every feed you read. An item with one in its title or text is hidden from you and not downloaded for you. Each feed's settings can add more. -
-
-
- Signals pick what matters out of everything you subscribe to, on your own - key from typesafe.ai. - The key is kept on this server and never shown again.
+ ${S.me?.signals==='server'?`
+ Signals pick what matters out of everything you subscribe to. This server + pays for them, so there is nothing to set up.
`:`
+ + Signals pick what matters out of everything you subscribe to. To use Signals + you will need an API key from typesafe.ai. +
+ +
+
+ Checked with TypeSafe before it is saved, kept on this server and never shown again.
`}
@@ -476,18 +481,31 @@ async function prefsModal(){ $('#tokval').select(); }catch(err){ toast(why(err),true); } }; - const saveKey=async(typesafe_key: string|null)=>{ - try{ - await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})}); - if(S.me) S.me.typesafe_key_set=!!typesafe_key; - $('#stskey').value=''; - $('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key'; - $('#stsforget').hidden=!typesafe_key; - toast(typesafe_key?'Saved':'Forgotten'); - }catch(err){ toast(why(err),true); } - }; - $('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); }; - $('#stsforget').onclick=()=>saveKey(null); + // Your own key, unless the server's is used for everyone (#271). Signals are on once TypeSafe + // has taken a key, and off again when it is forgotten. + if(S.me?.signals!=='server'){ + const saveKey=async(typesafe_key: string|null)=>{ + try{ + await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})}); + if(S.me) S.me.typesafe_key_set=!!typesafe_key; + $('#stskey').value=''; + $('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key'; + $('#stsforget').hidden=!typesafe_key; + $('#ssigon').checked=!!typesafe_key; $('#ssigkey').hidden=!typesafe_key; + toast(typesafe_key?'Signals are on':'Signals are off'); + renderFeeds(); + }catch(err){ toast(why(err),true); } + }; + $('#ssigon').onchange=(e: Event)=>{ + const on=(e.target as HTMLInputElement).checked; + if(on){ $('#ssigkey').hidden=false; $('#stskey').focus(); return; } + if(!S.me?.typesafe_key_set){ $('#ssigkey').hidden=true; return; } + if(confirm('Turn Signals off and forget your TypeSafe key? Your signals are kept for when you turn them on again.')) saveKey(null); + else (e.target as HTMLInputElement).checked=true; + }; + $('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); }; + $('#stsforget').onclick=()=>$('#ssigon').click(); + } $('#sblock').onchange=async (e: Event)=>{ const blocked=splitWords((e.target as HTMLInputElement).value); try{ diff --git a/web/src/signals.ts b/web/src/signals.ts index 600c947..3439039 100644 --- a/web/src/signals.ts +++ b/web/src/signals.ts @@ -18,6 +18,10 @@ function signalOf(view: string|null){ return id===null ? undefined : S.signals.find(s=>s.id===id); } +/// Whether you have Signals (#271): everyone does on the server's key; on your own, once you have +/// turned them on in Settings with a key TypeSafe took. +const signalsOn=()=>S.me?.signals==='server'||!!S.me?.typesafe_key_set; + /// The Signals folder: first in the feed list, under All Subscriptions, open or closed as an OPML's /// is (#255, #256). As rows under the places, a long list of them pushed the feeds down. const SIGNALS=':signals'; @@ -42,6 +46,7 @@ function signalTile(sg: {name: string, icon: string|null}){ } const badgeHTML=(n: number)=>`${n>999?'999+':n}`; function signalFolder(list: HTMLElement, q: string){ + if(!signalsOn()) return; const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q)); if(q&&!mine.length) return; const open=expanded.has(SIGNALS)||!!q; @@ -101,7 +106,7 @@ function renderSignals(){ } function signalHead(sg: Signal){ - const nokey=S.me&&!S.me.typesafe_key_set; + const nokey=S.me&&!signalsOn(); return `
${signalTile(sg)} @@ -130,7 +135,7 @@ async function signalAction(a: string, sg: Signal){ /// Making a Signal, or changing one. function signalModal(sg?: Signal){ - const nokey=S.me&&!S.me.typesafe_key_set; + const nokey=S.me&&!signalsOn(); openModal(`

${sg?'Edit signal':'New signal'}

+ your sentence together. The rest stays noise, in its own feed.${S.me?.signals==='server'?'':' Each item is one call on your TypeSafe key.'}${sg?' A change here reads everything again.':''}
${nokey?`

Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}

`:''}
`); @@ -280,6 +284,7 @@ const KEY_LINKS=`Ge async function keySetting(){ closeModal(); await prefsModal(); + $('#ssigon').checked=true; $('#ssigkey').hidden=false; const k=$('#stskey'); k.scrollIntoView({block:'center'}); k.focus(); } document.addEventListener('click',(e: MouseEvent)=>{ diff --git a/web/src/types.ts b/web/src/types.ts index 495fba1..a9d2498 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -84,6 +84,8 @@ interface Me { version: string; /// A TypeSafe key is saved for Signals; the key itself never comes back. typesafe_key_set: boolean; + /// Whose TypeSafe key Signals are judged on (#271): the server's, for everyone, or each person's own. + signals: 'server' | 'own'; } /// A Signal in your sidebar, `/api/signals` (db.rs SignalRow).