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 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### 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).
|
- `/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
|
## [1.3.0] - 2026-10-16
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ pub struct General {
|
|||||||
pub directory_token: String,
|
pub directory_token: String,
|
||||||
/// Send the directory the public feeds people here subscribe to, and how many of them do.
|
/// Send the directory the public feeds people here subscribe to, and how many of them do.
|
||||||
pub directory_share: bool,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
@@ -207,6 +210,7 @@ impl Default for General {
|
|||||||
directory_url: String::new(),
|
directory_url: String::new(),
|
||||||
directory_token: String::new(),
|
directory_token: String::new(),
|
||||||
directory_share: false,
|
directory_share: false,
|
||||||
|
signals_on_server: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,6 +335,8 @@ pub struct Stored {
|
|||||||
pub directory_token: String,
|
pub directory_token: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub directory_share: bool,
|
pub directory_share: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub signals_on_server: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn art_cache_mb() -> u64 {
|
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.
|
/// `[general]` keys that live in the database once it holds the configuration.
|
||||||
const STORED_KEYS: [&str; 9] = [
|
const STORED_KEYS: [&str; 10] = [
|
||||||
"schedule",
|
"schedule",
|
||||||
"max_total_gb",
|
"max_total_gb",
|
||||||
"max_age_days",
|
"max_age_days",
|
||||||
@@ -348,6 +354,7 @@ const STORED_KEYS: [&str; 9] = [
|
|||||||
"directory_url",
|
"directory_url",
|
||||||
"directory_token",
|
"directory_token",
|
||||||
"directory_share",
|
"directory_share",
|
||||||
|
"signals_on_server",
|
||||||
];
|
];
|
||||||
|
|
||||||
impl Stored {
|
impl Stored {
|
||||||
@@ -363,6 +370,7 @@ impl Stored {
|
|||||||
directory_url: g.directory_url.clone(),
|
directory_url: g.directory_url.clone(),
|
||||||
directory_token: g.directory_token.clone(),
|
directory_token: g.directory_token.clone(),
|
||||||
directory_share: g.directory_share,
|
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_url = self.directory_url;
|
||||||
g.directory_token = self.directory_token;
|
g.directory_token = self.directory_token;
|
||||||
g.directory_share = self.directory_share;
|
g.directory_share = self.directory_share;
|
||||||
|
g.signals_on_server = self.signals_on_server;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2579,7 +2579,8 @@ pub struct Ask {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Unjudged {
|
pub struct Unjudged {
|
||||||
pub user_id: i64,
|
pub user_id: i64,
|
||||||
pub key: String,
|
/// Their own key; None when the server's is used for everyone.
|
||||||
|
pub key: Option<String>,
|
||||||
pub feed_id: String,
|
pub feed_id: String,
|
||||||
pub guid: String,
|
pub guid: String,
|
||||||
pub feed_title: Option<String>,
|
pub feed_title: Option<String>,
|
||||||
@@ -2704,8 +2705,9 @@ impl Db {
|
|||||||
|
|
||||||
/// Up to `limit` items, newest first, that someone with a key has a Signal not yet asked
|
/// 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.
|
/// about: from the feeds they subscribe to, the last `SIGNAL_DAYS`, and not hidden from them.
|
||||||
/// `skip` are people whose key TypeSafe has refused.
|
/// `skip` are people whose key TypeSafe has refused. With `everyone`, the server's key is
|
||||||
pub async fn unjudged(&self, skip: &[i64], limit: i64) -> Result<Vec<Unjudged>> {
|
/// 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<Vec<Unjudged>> {
|
||||||
let mut a = Args::default();
|
let mut a = Args::default();
|
||||||
let since = a.p(now() - SIGNAL_DAYS * 86_400);
|
let since = a.p(now() - SIGNAL_DAYS * 86_400);
|
||||||
let skip = if skip.is_empty() {
|
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::<Vec<_>>().join(", "))
|
format!("AND g.user_id NOT IN ({})", skip.iter().map(|u| a.p(*u)).collect::<Vec<_>>().join(", "))
|
||||||
};
|
};
|
||||||
let limit = a.p(limit);
|
let limit = a.p(limit);
|
||||||
|
let keyed = if everyone { "true" } else { "u.typesafe_key IS NOT NULL" };
|
||||||
let sql = format!(
|
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,
|
"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
|
e.description, f.title AS feed_title
|
||||||
@@ -2722,7 +2725,7 @@ impl Db {
|
|||||||
JOIN subscriptions s ON s.user_id = g.user_id
|
JOIN subscriptions s ON s.user_id = g.user_id
|
||||||
JOIN entries e ON e.feed_id = s.feed_id
|
JOIN entries e ON e.feed_id = s.feed_id
|
||||||
LEFT JOIN feeds f ON f.id = e.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 coalesce(e.published, e.first_seen) >= {since}
|
||||||
AND NOT EXISTS (SELECT 1 FROM signal_verdicts v
|
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)
|
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();
|
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();
|
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();
|
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();
|
let mut seen: Vec<_> = queue.iter().map(|u| (u.guid.as_str(), u.signals.len())).collect();
|
||||||
seen.sort();
|
seen.sort();
|
||||||
assert_eq!(seen, [("x", 2), ("y", 2)], "each recent subscribed item once, with both Signals; old and unsubscribed left out");
|
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();
|
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_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("f", "x", &[(f1, true), (food, false)]).await.unwrap();
|
||||||
db.record_verdicts("g", "y", &[(f1, false), (food, true)]).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 order = order_sql("published", "desc");
|
||||||
let page = db.signal_entries(1, f1, Filter::All, None, 0, 50, &order).await.unwrap();
|
let page = db.signal_entries(1, f1, Filter::All, None, 0, 50, &order).await.unwrap();
|
||||||
|
|||||||
@@ -1837,7 +1837,7 @@ async fn scan_one(
|
|||||||
&& (force || stored.entries == 0)
|
&& (force || stored.entries == 0)
|
||||||
&& !parsed.entries.is_empty()
|
&& !parsed.entries.is_empty()
|
||||||
&& ctx.cfg().feeds.get(id).is_some_and(|f| f.category.is_none())
|
&& 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 {
|
match feed::classify(&ctx.client, &key, &parsed).await {
|
||||||
Ok(category) => {
|
Ok(category) => {
|
||||||
|
|||||||
@@ -46,10 +46,27 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String> {
|
||||||
|
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<String> {
|
||||||
|
std::env::var("TYPESAFE_KEY").ok().filter(|k| !k.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// One batch. True when there may be more waiting.
|
/// One batch. True when there may be more waiting.
|
||||||
async fn pass(ctx: &Ctx) -> Result<bool> {
|
async fn pass(ctx: &Ctx) -> Result<bool> {
|
||||||
let skip: Vec<i64> = ctx.signals_refused.lock().unwrap().iter().copied().collect();
|
let server = server_key(ctx);
|
||||||
let items = ctx.db.unjudged(&skip, BATCH).await?;
|
// People's refused keys are not what is asked with when the server's is.
|
||||||
|
let skip: Vec<i64> = 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() {
|
if items.is_empty() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -63,7 +80,8 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
|
|||||||
if refused.contains(&item.user_id) {
|
if refused.contains(&item.user_id) {
|
||||||
continue;
|
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) => {
|
Ok(verdicts) => {
|
||||||
judged += 1;
|
judged += 1;
|
||||||
let hits = verdicts.iter().filter(|(_, h)| *h).count();
|
let hits = verdicts.iter().filter(|(_, h)| *h).count();
|
||||||
@@ -77,6 +95,12 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
|
|||||||
}
|
}
|
||||||
// A key TypeSafe will not take is not sent the rest of the queue. It is tried
|
// 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.
|
// 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) => {
|
Err(e) if refuses_key(&e) => {
|
||||||
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
|
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
|
||||||
refused.insert(item.user_id);
|
refused.insert(item.user_id);
|
||||||
@@ -136,7 +160,7 @@ fn full(items: &[Unjudged]) -> bool {
|
|||||||
items.iter().map(|i| i.signals.len()).sum::<usize>() as i64 >= BATCH
|
items.iter().map(|i| i.signals.len()).sum::<usize>() 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))
|
matches!(status(e), Some(401 | 402 | 403))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +168,7 @@ fn malformed(e: &anyhow::Error) -> bool {
|
|||||||
matches!(status(e), Some(400 | 422))
|
matches!(status(e), Some(400 | 422))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn judge(ctx: &Ctx, item: &Unjudged) -> Result<Vec<(i64, bool)>> {
|
async fn judge(ctx: &Ctx, item: &Unjudged, key: &str) -> Result<Vec<(i64, bool)>> {
|
||||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
|
||||||
let span = tracing::info_span!(
|
let span = tracing::info_span!(
|
||||||
"jev",
|
"jev",
|
||||||
user = item.user_id,
|
user = item.user_id,
|
||||||
@@ -154,17 +177,7 @@ async fn judge(ctx: &Ctx, item: &Unjudged) -> Result<Vec<(i64, bool)>> {
|
|||||||
error = tracing::field::Empty,
|
error = tracing::field::Empty,
|
||||||
"otel.status_code" = tracing::field::Empty,
|
"otel.status_code" = tracing::field::Empty,
|
||||||
);
|
);
|
||||||
let answer: serde_json::Value = called(span.clone(), async {
|
let answer = called(span, ask(ctx, key, &request(item))).await?;
|
||||||
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?;
|
|
||||||
// A question Jev left unanswered stays unjudged and is asked again.
|
// 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())
|
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<bool> {
|
|||||||
/// ipx-web's, and sends the likeliest. On the server's key, not the Signal owner's: it is the
|
/// 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.
|
/// 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<String> {
|
pub async fn pick_icon(ctx: &Ctx, key: &str, name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> Result<String> {
|
||||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
|
||||||
let span = tracing::info_span!(
|
let span = tracing::info_span!(
|
||||||
"jev_icon",
|
"jev_icon",
|
||||||
candidates = candidates.len(),
|
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,
|
"otel.status_code" = tracing::field::Empty,
|
||||||
);
|
);
|
||||||
called(span, async {
|
called(span, async {
|
||||||
let r = jev_post(ctx, url, key).json(&icon_request(name, words, description, candidates)).send().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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<serde_json::Value> {
|
||||||
|
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();
|
let status = r.status().as_u16();
|
||||||
tracing::Span::current().record("http.response.status_code", status);
|
tracing::Span::current().record("http.response.status_code", status);
|
||||||
if !r.status().is_success() {
|
if !r.status().is_success() {
|
||||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||||
return Err(Refused { status, body }.into());
|
return Err(Refused { status, body }.into());
|
||||||
}
|
}
|
||||||
let answer: serde_json::Value = r.json().await?;
|
Ok(r.json().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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a call to Jev in `span`, counting it in /metrics by how it ended, and marking the span
|
/// 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]
|
#[test]
|
||||||
fn a_batch_is_full_by_its_rows_not_its_items() {
|
fn a_batch_is_full_by_its_rows_not_its_items() {
|
||||||
let item = |n: usize| Unjudged {
|
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(),
|
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::<Vec<_>>()), "25 items of two Signals each is the 50 asked for");
|
assert!(full(&(0..25).map(|_| item(2)).collect::<Vec<_>>()), "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() {
|
fn each_signal_is_its_own_question_on_the_items_words() {
|
||||||
let item = Unjudged {
|
let item = Unjudged {
|
||||||
user_id: 1,
|
user_id: 1,
|
||||||
key: "k".into(),
|
key: Some("k".into()),
|
||||||
feed_id: "f".into(),
|
feed_id: "f".into(),
|
||||||
guid: "g".into(),
|
guid: "g".into(),
|
||||||
feed_title: Some("Pit Lane".into()),
|
feed_title: Some("Pit Lane".into()),
|
||||||
|
|||||||
@@ -387,6 +387,8 @@ async fn me(
|
|||||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
|
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
|
||||||
"deadair_saved": saved, "theme": theme, "version": env!("CARGO_PKG_VERSION"),
|
"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),
|
"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())) {
|
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"));
|
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.db.set_typesafe_key(user.id, key.as_deref()).await?;
|
||||||
state.ctx.signals_refused.lock().unwrap().remove(&user.id);
|
state.ctx.signals_refused.lock().unwrap().remove(&user.id);
|
||||||
state.ctx.signals.notify_one();
|
state.ctx.signals.notify_one();
|
||||||
@@ -517,7 +529,7 @@ async fn pick_signal_icon(
|
|||||||
_user: crate::db::User,
|
_user: crate::db::User,
|
||||||
Json(body): Json<IconAsk>,
|
Json(body): Json<IconAsk>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, 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"));
|
return Err(ApiError::not_found("picking an icon is not set up on this server"));
|
||||||
};
|
};
|
||||||
let name = signal_name(&body.name)?;
|
let name = signal_name(&body.name)?;
|
||||||
@@ -2319,6 +2331,9 @@ struct Settings {
|
|||||||
/// Whether a token is set: the token itself never leaves the server.
|
/// Whether a token is set: the token itself never leaves the server.
|
||||||
directory_token_set: bool,
|
directory_token_set: bool,
|
||||||
directory_share: 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<WebState>) -> Json<Settings> {
|
async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
|
||||||
@@ -2335,6 +2350,8 @@ async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
|
|||||||
directory_url: cfg.general.directory_url.clone(),
|
directory_url: cfg.general.directory_url.clone(),
|
||||||
directory_token_set: !cfg.general.directory_token.trim().is_empty(),
|
directory_token_set: !cfg.general.directory_token.trim().is_empty(),
|
||||||
directory_share: cfg.general.directory_share,
|
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.
|
/// Replaces the token; empty clears it.
|
||||||
directory_token: Option<String>,
|
directory_token: Option<String>,
|
||||||
directory_share: Option<bool>,
|
directory_share: Option<bool>,
|
||||||
|
signals_on_server: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn patch_settings(
|
async fn patch_settings(
|
||||||
@@ -2403,7 +2421,16 @@ async fn patch_settings(
|
|||||||
if let Some(v) = body.directory_share {
|
if let Some(v) = body.directory_share {
|
||||||
cfg.general.directory_share = v;
|
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?;
|
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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ module.exports = defineConfig({
|
|||||||
// production's Tempo and never opens production's database.
|
// production's Tempo and never opens production's database.
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
||||||
IPX_DATABASE_URL: '',
|
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',
|
IPX_TYPESAFE_URL: 'http://127.0.0.1:8792/jev',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']);
|
||||||
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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="settings"]').click(),
|
||||||
() => page.locator('#content .acts [data-a="dl"]').click(),
|
() => page.locator('#content .acts [data-a="dl"]').click(),
|
||||||
() => page.locator('#content .acts [data-a="rm"]').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) {
|
for (const open of dialogs) {
|
||||||
await open();
|
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.keyboard.press('Escape');
|
||||||
await page.request.delete(`/api/signals/${id}`);
|
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 } });
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ http.createServer((req, res) => {
|
|||||||
let body = '';
|
let body = '';
|
||||||
req.on('data', c => { body += c; });
|
req.on('data', c => { body += c; });
|
||||||
req.on('end', () => {
|
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);
|
const { state, questions } = JSON.parse(body);
|
||||||
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
|
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
|
||||||
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an 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 title = String(state).toLowerCase();
|
||||||
const answers = {};
|
const answers = {};
|
||||||
for (const [id, q] of Object.entries(questions)) {
|
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 };
|
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 }));
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ answers }));
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.1.0] - 2026-10-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ async function drawServer(){
|
|||||||
<input type="number" id="gart" min="0" value="${g.art_cache_mb}">
|
<input type="number" id="gart" min="0" value="${g.art_cache_mb}">
|
||||||
<span class="hint">Show and episode artwork is kept here once shown, so it loads from iPX
|
<span class="hint">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.</span></div>
|
instead of each publisher. Over this, what has gone longest unshown is dropped first.</span></div>
|
||||||
|
<div class="field"><label class="check"><input type="checkbox" id="gsig"${g.signals_on_server ? ' checked' : ''}${g.typesafe_server_key ? '' : ' disabled'}>
|
||||||
|
Signals for everyone, on this server's TypeSafe key</label>
|
||||||
|
<span class="hint">${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 (<code>TYPESAFE_KEY</code>), so each person uses their own.'}</span></div>
|
||||||
<div class="field"><label>Download folder</label>
|
<div class="field"><label>Download folder</label>
|
||||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
||||||
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
||||||
@@ -64,7 +69,9 @@ async function drawServer(){
|
|||||||
media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
|
media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
|
||||||
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
|
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
|
||||||
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
|
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
|
||||||
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0)})});
|
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0),
|
||||||
|
// Left alone while the server has no key to offer, rather than switched off.
|
||||||
|
...($<HTMLInputElement>('#gsig').disabled ? {} : {signals_on_server: $<HTMLInputElement>('#gsig').checked})})});
|
||||||
toast('Settings saved');
|
toast('Settings saved');
|
||||||
}catch(e){ toast(why(e), true); }
|
}catch(e){ toast(why(e), true); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -440,13 +440,18 @@ async function prefsModal(){
|
|||||||
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
|
<span class="hint">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
|
one in its title or text is hidden from you and not downloaded for you. Each feed's
|
||||||
settings can add more.</span></div>
|
settings can add more.</span></div>
|
||||||
<div class="field"><label for="stskey">TypeSafe key</label>
|
${S.me?.signals==='server'?`<div class="field"><label>Signals</label>
|
||||||
|
<span class="hint">Signals pick what matters out of everything you subscribe to. This server
|
||||||
|
pays for them, so there is nothing to set up.</span></div>`:`<div class="field">
|
||||||
|
<label class="check"><input type="checkbox" id="ssigon"${S.me?.typesafe_key_set?' checked':''}> Signals</label>
|
||||||
|
<span class="hint">Signals pick what matters out of everything you subscribe to. To use Signals
|
||||||
|
you will need an API key from <a href="https://typesafe.ai" target="_blank" rel="noopener">typesafe.ai</a>.</span>
|
||||||
|
<div id="ssigkey"${S.me?.typesafe_key_set?'':' hidden'}>
|
||||||
|
<label for="stskey">TypeSafe key</label>
|
||||||
<div class="inline"><input type="password" id="stskey" autocomplete="off" spellcheck="false"
|
<div class="inline"><input type="password" id="stskey" autocomplete="off" spellcheck="false"
|
||||||
placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}">
|
placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}">
|
||||||
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
|
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
|
||||||
<span class="hint">Signals pick what matters out of everything you subscribe to, on your own
|
<span class="hint">Checked with TypeSafe before it is saved, kept on this server and never shown again.</span></div></div>`}
|
||||||
key from <a href="https://typesafe.ai" target="_blank" rel="noopener">typesafe.ai</a>.
|
|
||||||
The key is kept on this server and never shown again.</span></div>
|
|
||||||
<div class="field boxed"><label for="tokname">API tokens</label>
|
<div class="field boxed"><label for="tokname">API tokens</label>
|
||||||
<div id="tokens"></div>
|
<div id="tokens"></div>
|
||||||
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
|
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
|
||||||
@@ -476,6 +481,9 @@ async function prefsModal(){
|
|||||||
$<HTMLInputElement>('#tokval').select();
|
$<HTMLInputElement>('#tokval').select();
|
||||||
}catch(err){ toast(why(err),true); }
|
}catch(err){ toast(why(err),true); }
|
||||||
};
|
};
|
||||||
|
// 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)=>{
|
const saveKey=async(typesafe_key: string|null)=>{
|
||||||
try{
|
try{
|
||||||
await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})});
|
await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})});
|
||||||
@@ -483,11 +491,21 @@ async function prefsModal(){
|
|||||||
$<HTMLInputElement>('#stskey').value='';
|
$<HTMLInputElement>('#stskey').value='';
|
||||||
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
|
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
|
||||||
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
|
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
|
||||||
toast(typesafe_key?'Saved':'Forgotten');
|
$<HTMLInputElement>('#ssigon').checked=!!typesafe_key; $('#ssigkey').hidden=!typesafe_key;
|
||||||
|
toast(typesafe_key?'Signals are on':'Signals are off');
|
||||||
|
renderFeeds();
|
||||||
}catch(err){ toast(why(err),true); }
|
}catch(err){ toast(why(err),true); }
|
||||||
};
|
};
|
||||||
|
$<HTMLInputElement>('#ssigon').onchange=(e: Event)=>{
|
||||||
|
const on=(e.target as HTMLInputElement).checked;
|
||||||
|
if(on){ $('#ssigkey').hidden=false; $<HTMLInputElement>('#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;
|
||||||
|
};
|
||||||
$<HTMLInputElement>('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); };
|
$<HTMLInputElement>('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); };
|
||||||
$<HTMLButtonElement>('#stsforget').onclick=()=>saveKey(null);
|
$<HTMLButtonElement>('#stsforget').onclick=()=>$<HTMLInputElement>('#ssigon').click();
|
||||||
|
}
|
||||||
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
|
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
|
||||||
const blocked=splitWords((e.target as HTMLInputElement).value);
|
const blocked=splitWords((e.target as HTMLInputElement).value);
|
||||||
try{
|
try{
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ function signalOf(view: string|null){
|
|||||||
return id===null ? undefined : S.signals.find(s=>s.id===id);
|
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
|
/// 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.
|
/// is (#255, #256). As rows under the places, a long list of them pushed the feeds down.
|
||||||
const SIGNALS=':signals';
|
const SIGNALS=':signals';
|
||||||
@@ -42,6 +46,7 @@ function signalTile(sg: {name: string, icon: string|null}){
|
|||||||
}
|
}
|
||||||
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
|
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
|
||||||
function signalFolder(list: HTMLElement, q: string){
|
function signalFolder(list: HTMLElement, q: string){
|
||||||
|
if(!signalsOn()) return;
|
||||||
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
|
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
|
||||||
if(q&&!mine.length) return;
|
if(q&&!mine.length) return;
|
||||||
const open=expanded.has(SIGNALS)||!!q;
|
const open=expanded.has(SIGNALS)||!!q;
|
||||||
@@ -101,7 +106,7 @@ function renderSignals(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function signalHead(sg: Signal){
|
function signalHead(sg: Signal){
|
||||||
const nokey=S.me&&!S.me.typesafe_key_set;
|
const nokey=S.me&&!signalsOn();
|
||||||
return `
|
return `
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
${signalTile(sg)}
|
${signalTile(sg)}
|
||||||
@@ -130,7 +135,7 @@ async function signalAction(a: string, sg: Signal){
|
|||||||
|
|
||||||
/// Making a Signal, or changing one.
|
/// Making a Signal, or changing one.
|
||||||
function signalModal(sg?: Signal){
|
function signalModal(sg?: Signal){
|
||||||
const nokey=S.me&&!S.me.typesafe_key_set;
|
const nokey=S.me&&!signalsOn();
|
||||||
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
|
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
|
||||||
<div class="field"><label for="sgname">Name</label>
|
<div class="field"><label for="sgname">Name</label>
|
||||||
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
|
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
|
||||||
@@ -148,8 +153,7 @@ function signalModal(sg?: Signal){
|
|||||||
<input type="text" id="sgdesc" value="${esc(sg?.description||'')}" placeholder="AI tools for writing software: coding agents, vibe coding">
|
<input type="text" id="sgdesc" value="${esc(sg?.description||'')}" placeholder="AI tools for writing software: coding agents, vibe coding">
|
||||||
<span class="hint">Every item in the feeds you subscribe to is read, the last 30 days of them
|
<span class="hint">Every item in the feeds you subscribe to is read, the last 30 days of them
|
||||||
and each new one, and the ones about this subject are pulled out: its name, these words and
|
and each new one, and the ones about this subject are pulled out: its name, these words and
|
||||||
your sentence together. The rest stays noise, in its own feed. Each item is one call on your
|
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.':''}</span></div>
|
||||||
TypeSafe key.${sg?' A change here reads everything again.':''}</span></div>
|
|
||||||
${nokey?`<p class="hint" style="color:var(--warn)">Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}</p>`:''}
|
${nokey?`<p class="hint" style="color:var(--warn)">Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}</p>`:''}
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
|
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
|
||||||
@@ -280,6 +284,7 @@ const KEY_LINKS=`<a href="https://typesafe.ai" target="_blank" rel="noopener">Ge
|
|||||||
async function keySetting(){
|
async function keySetting(){
|
||||||
closeModal();
|
closeModal();
|
||||||
await prefsModal();
|
await prefsModal();
|
||||||
|
$<HTMLInputElement>('#ssigon').checked=true; $('#ssigkey').hidden=false;
|
||||||
const k=$<HTMLInputElement>('#stskey'); k.scrollIntoView({block:'center'}); k.focus();
|
const k=$<HTMLInputElement>('#stskey'); k.scrollIntoView({block:'center'}); k.focus();
|
||||||
}
|
}
|
||||||
document.addEventListener('click',(e: MouseEvent)=>{
|
document.addEventListener('click',(e: MouseEvent)=>{
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ interface Me {
|
|||||||
version: string;
|
version: string;
|
||||||
/// A TypeSafe key is saved for Signals; the key itself never comes back.
|
/// A TypeSafe key is saved for Signals; the key itself never comes back.
|
||||||
typesafe_key_set: boolean;
|
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).
|
/// A Signal in your sidebar, `/api/signals` (db.rs SignalRow).
|
||||||
|
|||||||
Reference in New Issue
Block a user