Docs and site: who pays for Signals, and Signals in the API reference (#271, #269)

docs/signals.md says what the admin's setting does and that a key is
checked before it is saved; the install guide lists TYPESAFE_KEY; the API
reference has Signals' routes and types and the new fields of Me and
Settings; the home page says an admin may provide the key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 13:40:09 +00:00
parent 1aef6ba421
commit 026fbd1622
4 changed files with 113 additions and 17 deletions

View File

@@ -6,13 +6,21 @@ mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and p
about that subject: the name, the words and the sentence together, not any one word. Those items show in the Signal as well as in their
own feed. The rest stays noise, where it was.
Each person brings their own TypeSafe key, entered in Settings. Nothing is judged for someone
without one. Jev is asked once for each item, on that person's key, with one question for each of
their Signals that has not heard about the item yet.
Whose TypeSafe key pays for Jev is the admin's choice (#271), under Admin, Server, **Signals for
everyone, on this server's TypeSafe key** (`signals_on_server`):
- **On**, with the server's key in `TYPESAFE_KEY`: everyone has Signals, with nothing to set up.
Every item is asked about on the server's key, and keys people saved are kept but not used.
- **Off**, the default, or on with no `TYPESAFE_KEY`: each person turns Signals on in Settings
with a key of their own. Nothing is judged for someone without one, and their Signals folder is
not shown.
Jev is asked once for each item, on that key, with one question for each of the person's Signals
that has not heard about the item yet.
## What someone sees
- **The sidebar.** A Signals folder comes first in the feed list, under All Subscriptions (#255,
- **The sidebar.** Once Signals are on for someone, a Signals folder comes first in the feed list, under All Subscriptions (#255,
#256). It is closed or open as an OPML's folder is, remembered in each browser. Its row is like
any folder's (#265); New signal is the + on the folder's page. Inside the folder are the
person's Signals, each with its items and unread counted under its name.
@@ -24,13 +32,17 @@ their Signals that has not heard about the item yet.
- **Each Signal's row** says "12 items · 3 unread", as a feed's row counts its own.
- **The folder's page** lists the Signals, as an OPML's page lists its feeds, with the + for a new one.
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
Its header says what it listens for (the sentence, or the words), and that nothing is picked
out without a key. Edit changes the name, the words or the sentence; Delete removes the Signal
Its header says what it listens for (the sentence, or the words), and, on people's own keys,
that nothing is picked out without one. Edit changes the name, the words or the sentence; Delete removes the Signal
and leaves the items in their feeds.
- **Read state is the item's.** Reading an item in a Signal reads it in its feed, and the other way
round. The counts follow without a reload (#258).
- **Settings, TypeSafe key.** A password field. A key saves on leaving the field, and the bin
forgets it. The key is never sent back to the page, which only learns whether one is set.
- **Settings, Signals.** On the server's key, a line saying the server pays. On people's own, a
Signals switch: turning it on shows a password field for the key, which saves on leaving the
field once TypeSafe has taken it (one short question on that key); a key TypeSafe refuses is
not saved, and the page says so. Turning the switch off, or the bin, forgets the key and hides
the folder; the Signals are kept. The key is never sent back to the page, which only learns
whether one is set.
## How an item gets into a Signal
@@ -94,7 +106,7 @@ Questions in one call are judged independently and in parallel.
### A request
Sent with `Authorization: Bearer <the person's key>`, for an item and two of their Signals:
Sent with `Authorization: Bearer <the key>`, the server's or the person's, for an item and two of their Signals:
```json
{
@@ -155,7 +167,8 @@ missing from the answer is left unjudged and asked again next pass.
| TypeSafe answers | What the judge does |
|---|---|
| 401, 402, 403 | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". |
| 401, 402, 403, on the server's key | Everyone's key is refused. The pass ends and the queue waits for the next one, five minutes on. Logged: "TypeSafe refused the server's key for Signals". |
| 401, 402, 403, on a person's key | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". |
| 400, 422 | The request itself is wrong, so every item's would be. The pass ends; the items wait for the next one. Logged: "TypeSafe refused a Signals request as malformed", with TypeSafe's reason. |
| anything else, or no answer | That item is left unjudged and asked again next pass. Logged: "Jev could not judge an item". |
@@ -238,9 +251,18 @@ anywhere else.
{ "typesafe_key": "ts_…" }
```
A string saves the key and lets the judge try it again. `null` or an empty string forgets it. A
key is up to 500 printable characters. `GET /api/me` says `"typesafe_key_set": true` and nothing
more.
A string is checked with TypeSafe, by one short question on that key, then saved, and the judge
tries it again. `null` or an empty string forgets it. A key is up to 500 printable characters.
A key TypeSafe refuses is a `400` ("TypeSafe did not accept that key") and not saved; TypeSafe not
answering is a `502`, and the key is not saved either. `GET /api/me` says `"typesafe_key_set":
true` and nothing more about the key, and `"signals": "server"` or `"own"`, whose key is used.
### `PATCH /api/settings`, who pays
An admin's `{ "signals_on_server": true }` judges everyone's Signals on `TYPESAFE_KEY`, and
`false` on each person's own. `GET /api/settings` has `signals_on_server` and
`typesafe_server_key`, whether the server has a key to use; without one the setting changes
nothing. A change wakes the judge and lets keys refused before be tried again.
### `POST /api/signals/icon`

View File

@@ -67,9 +67,11 @@ export const ipx = {
},
{
method: 'PATCH', path: '/api/me', slug: 'patch-me',
summary: 'Change the words that hide an item in every feed you read.',
summary: 'Change the words that hide an item in every feed you read, or your TypeSafe key for Signals.',
doc: '<p>A key is checked with TypeSafe, by one short question on it, before it is saved.</p>',
body: 'MePatch', returns: '204 No Content',
errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters.']],
errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters; or a key TypeSafe did not accept.'],
['502 Bad Gateway', 'TypeSafe could not be asked about the key just now. It is not saved.']],
example: ipxCurl('PATCH', '/api/me', '{"blocked":["sponsor","ad read"]}'),
},
{
@@ -205,6 +207,54 @@ export const ipx = {
},
],
},
{
id: 'signals', name: 'signals', title: 'Signals',
summary: 'Subjects picked out of every feed you subscribe to by TypeSafe.',
items: [
{
method: 'GET', path: '/api/signals', slug: 'get-signals',
summary: 'Your Signals, each with its items and unread counted.',
returns: 'Vec<Signal>',
example: ipxCurl('GET', '/api/signals'),
},
{
method: 'POST', path: '/api/signals', slug: 'post-signals',
summary: 'Make a Signal. It looks back over the last 30 days, then at each new item.',
doc: '<p>Each item is one call to TypeSafe: on the server’s key when an admin has chosen that (<code>signals_on_server</code>), otherwise on yours, saved with <a href="/docs/api/ipx/auth/patch-me"><code>PATCH /api/me</code></a>. Without a key, nothing is picked out.</p>',
body: 'SignalBody', returns: '{ id: i64 }',
errors: [['400 Bad Request', 'No name or one over 100 characters, not 1 to 50 words, a description over 500 characters, or an icon that is not a style and a name.']],
example: ipxCurl('POST', '/api/signals', '{"name":"Formula 1","keywords":["formula 1","f1"],"description":"Grand prix racing, not road cars."}'),
},
{
method: 'PATCH', path: '/api/signals/{id}', slug: 'patch-signal',
summary: 'Change a Signal. A new name, words or description reads everything again; a new icon does not.',
pathParams: [['id', 'i64', 'The Signal’s id.']],
body: 'SignalBody', returns: '204 No Content',
errors: [['400 Bad Request', 'As for a new Signal.'], ['404 Not Found', 'No Signal of yours has that id.']],
example: ipxCurl('PATCH', '/api/signals/3', '{"keywords":["f1","grand prix"]}'),
},
{
method: 'DELETE', path: '/api/signals/{id}', slug: 'delete-signal',
summary: 'Delete a Signal. Its items stay in their feeds.',
pathParams: [['id', 'i64', 'The Signal’s id.']],
returns: '204 No Content', errors: [['404 Not Found', 'No Signal of yours has that id.']],
},
{
method: 'GET', path: '/api/signals/{id}/entries', slug: 'get-signal-entries',
summary: 'A page of the items picked out for a Signal, as a feed’s list is.',
pathParams: [['id', 'i64', 'The Signal’s id.']],
query: 'Page', returns: 'EntryPage', errors: [['404 Not Found', 'No Signal of yours has that id.']],
example: ipxCurl('GET', '/api/signals/3/entries?filter=unread&limit=10'),
},
{
method: 'POST', path: '/api/signals/icon', slug: 'post-signal-icon',
summary: 'Have TypeSafe choose a Signal’s icon from the ones offered, on the server’s TypeSafe key.',
body: 'IconAsk', returns: '{ icon: String }',
errors: [['400 Bad Request', 'No icons, more than 60, or one that is not a style and a name.'],
['404 Not Found', 'The server has no TypeSafe key.'], ['502 Bad Gateway', 'TypeSafe could not choose just now.']],
},
],
},
{
id: 'files', name: 'files', title: 'Files',
summary: 'Downloading, deleting and playing an episode’s file.',
@@ -379,8 +429,27 @@ export const ipx = {
['sign_out', 'Option<String>', 'Where signing out should go, for someone single sign-on signed in.'],
['deadair_saved', 'i64', 'Seconds DeadAir has saved you, all told.'],
['version', 'String', 'ipx’s version.'],
['typesafe_key_set', 'bool', 'You have a TypeSafe key saved for Signals. The key itself is never sent back.'],
['signals', 'String', 'Whose key Signals are judged on: <code>server</code>, for everyone, or <code>own</code>, each person’s.'],
] },
{ name: 'MePatch', doc: 'What <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.'],
['typesafe_key', 'Option<Option<String>>', 'Your TypeSafe key for Signals, checked with TypeSafe and then saved; <code>null</code> or empty forgets it.']] },
{ name: 'Signal', doc: 'A Signal in your sidebar.', fields: [
['id', 'i64', ''], ['name', 'String', ''], ['keywords', 'Vec<String>', 'The words it listens for.'],
['description', 'Option<String>', 'What you mean, in your own words.'],
['icon', 'Option<String>', 'A Font Awesome icon, such as <code>solid/robot</code>; none shows its initials.'],
['items', 'i64', 'Items picked out that are still in a feed you subscribe to.'], ['unread', 'i64', 'Those of them not read.'],
] },
{ name: 'SignalBody', doc: 'A Signal to make, or what to change of one. Leave out what stays.', fields: [
['name', 'Option<String>', 'Up to 100 characters; needed for a new one.'],
['keywords', 'Option<Vec<String>>', '1 to 50 words, each up to 200 characters; needed for a new one.'],
['description', 'Option<Option<String>>', 'Up to 500 characters; <code>null</code> or empty clears it.'],
['icon', 'Option<Option<String>>', '<code>solid/</code>, <code>regular/</code> or <code>brands/</code> and a name; <code>null</code> or empty clears it.'],
] },
{ name: 'IconAsk', doc: 'The icons to choose a Signal’s from.', fields: [
['name', 'String', ''], ['keywords', 'Vec<String>', ''], ['description', 'Option<String>', ''],
['candidates', 'Vec<(String, String)>', '1 to 60 of <code>[icon, label]</code>, the label its name and search terms.'],
] },
{ name: 'MePatch', doc: 'What <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.']] },
{ name: 'Token', doc: 'An API token, as listed.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['created', 'i64', ''], ['last_used', 'Option<i64>', '']] },
{ name: 'NewToken', doc: 'A token to make.', fields: [['name', 'String', 'What it is for, to know it by when revoking it.']] },
{ name: 'NewTokenMade', doc: 'A token just made: the only time it is shown.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['token', 'String', 'Begins <code>ipx_</code>. Send it as <code>Authorization: Bearer</code>.']] },
@@ -493,6 +562,7 @@ export const ipx = {
['download_error', '{ feed, enclosure, msg, url }', ''],
['deadair_done', '{ feed, enclosure, saved_s }', 'DeadAir finished a file.'],
['scan_done', '{ feeds }', 'A check finished; how many feeds it looked at.'],
['signal_hits', '{ user, hits }', 'Items were picked out for someone’s Signals; theirs to fetch again.'],
] },
{ name: 'Settings', doc: 'The server’s settings.', fields: [
['schedule', 'String', 'How often feeds are checked, such as <code>every 60m</code>.'], ['every_mins', 'u64', 'The same in minutes.'],
@@ -502,12 +572,15 @@ export const ipx = {
['max_age_days', 'u64', '0 for no limit.'], ['art_cache_mb', 'u64', 'Artwork kept.'],
['directory_url', 'String', ''], ['directory_token_set', 'bool', 'The token itself is never sent back.'],
['directory_share', 'bool', ''],
['signals_on_server', 'bool', 'Everyone’s Signals are judged on the server’s TypeSafe key, not each person’s.'],
['typesafe_server_key', 'bool', 'The server has a TypeSafe key (<code>TYPESAFE_KEY</code>); without one, <code>signals_on_server</code> changes nothing.'],
] },
{ name: 'SettingsPatch', doc: 'What <code>PATCH /api/settings</code> changes. Leave out what stays.', fields: [
['schedule', 'Option<String>', ''], ['max_new_per_check', 'Option<usize>', ''], ['media_types', 'Option<Vec<String>>', ''],
['max_total_gb', 'Option<f64>', ''], ['max_age_days', 'Option<u64>', ''], ['art_cache_mb', 'Option<u64>', ''],
['directory_url', 'Option<String>', 'Empty for no directory.'], ['directory_token', 'Option<String>', 'Replaces it; empty clears it.'],
['directory_share', 'Option<bool>', ''],
['signals_on_server', 'Option<bool>', 'Judge everyone’s Signals on the server’s TypeSafe key.'],
] },
{ name: 'UserRow', doc: 'An account.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['admin', 'bool', ''], ['password', 'bool', 'Has one; without, it signs in only by single sign-on.'], ['created', 'Option<i64>', ''], ['last_login', 'Option<i64>', '']] },
{ name: 'NewUser', doc: '', fields: [['name', 'String', 'No commas, semicolons or line breaks.'], ['password', 'String', 'Empty for single sign-on only.'], ['admin', 'bool', '']] },

View File

@@ -50,6 +50,7 @@ docker build -f Dockerfile.web -t ipx-web .</code></pre>
<tbody>
<tr><td><code>IPX_DATABASE_URL</code></td><td>A <code>postgres://user:password@host:5432/database</code> address, to keep everything in Postgres instead of SQLite</td></tr>
<tr><td><code>PUID</code>, <code>PGID</code></td><td>The user and group that own the files ipx writes</td></tr>
<tr><td><code>TYPESAFE_KEY</code></td><td>The server's own <a href="https://typesafe.ai">typesafe.ai</a> API key: it picks a podcast's Directory category and a Signal's icon, and, when an admin turns it on, pays for everyone's Signals</td></tr>
<tr><td><code>IPX_LOG_FORMAT</code></td><td><code>json</code> logs one JSON object a line, for Loki and the like</td></tr>
<tr><td><code>OTEL_EXPORTER_OTLP_ENDPOINT</code></td><td>Sends traces of requests, checks and downloads to an OpenTelemetry collector; set <code>WEB_TRACING=on</code> on ipx-web too</td></tr>
<tr><td><code>IPX_UPSTREAM</code></td><td>On ipx-web: where ipx is, <code>ipx:8099</code> by default</td></tr>

View File

@@ -98,7 +98,7 @@
<div class="feature">
<div class="say">
<h3>Tune out the noise</h3>
<p>Your feeds bring in more than anyone has time for. A Signal listens across all of them for one subject: give it a name, the words to listen for, and a sentence saying what you mean if the words are not enough. What fits lands in a list of its own, starting with the last month, and it keeps listening. To use Signals you will need an API key from <a href="https://typesafe.ai">typesafe.ai</a>.</p>
<p>Your feeds bring in more than anyone has time for. A Signal listens across all of them for one subject: give it a name, the words to listen for, and a sentence saying what you mean if the words are not enough. What fits lands in a list of its own, starting with the last month, and it keeps listening. To use Signals you will need an API key from <a href="https://typesafe.ai">typesafe.ai</a>, unless your server's admin provides one.</p>
</div>
<figure class="frame"><img class="cut" src="/shots/signals.webp" width="874" height="398" loading="lazy"
alt="A Signal called Space, picked out of the noise of eight feeds: episodes and posts about galaxies, Artemis II, Mars and a rocket launch from Hackaday, Universe Today, NASA's Houston We Have a Podcast and Ars Technica."></figure>