The first step of moving to SeaORM (#18, phase 1). Nothing a user sees changes.
- src/entity.rs: the seven tables as SeaORM entities, matching the SQLite schema.
Strings are Text, as the columns are; yes/no columns are bool, which is BOOLEAN
on Postgres and stays INTEGER in the existing SQLite file (sync notes the
difference and leaves it alone).
- Db holds a SeaORM connection to the same SQLite file beside the rusqlite one;
functions move to it one at a time, and rusqlite goes with the last of them.
- db::sync creates what a database is missing from the entities (SeaORM's
schema-sync, experimental, so sea-orm is pinned to ~2.0), plus the two indexes
an entity cannot express. Checked against a copy of production: it added the
lower(name) index and changed nothing else.
- Test databases are now built from the entities alone, in a temporary file
(two connections to one ":memory:" are two databases), so every test also
checks that the entities describe what the queries need. That caught the one
difference: finding a user by name relied on COLLATE NOCASE, which Postgres
lacks; it now compares lower() on both sides.
- rusqlite steps back to 0.39: 0.40's libsqlite3-sys is newer than sqlx accepts,
and only one may link SQLite. It goes away at the end of this phase.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
order_sql takes pinned_first, which puts coalesce(s.flagged, 0) DESC ahead of
the chosen sort, so pins lead every list in whatever order is asked for and on
every page of it. Not when sorting by the pin column itself, where the direction
is the point, and not for Currently Listening. Pinning now asks for the list again
so the row moves at once, instead of redrawing it where it stood.
Closes#35.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/admin, with Server, Accounts and Log sections chosen by the URL's hash. The
server sends the page and /admin.js to admins only (anyone else asking for the
page goes back to the app, and the script is 403), and removes the header's link
to it from everyone else's page rather than hiding it. The API keeps refusing
all of it to non-admins as before.
Settings becomes personal: theme, OPML import and export, and the schedule and
download folder to read. The server fields, the Users dialog and the Log dialog
move out of dialogs.ts into admin.ts.
The CSS moves out of index.html into web/app.css, which both pages load as
/app.css?v=<hash>, served immutable like the scripts. The smoke test checks both
pages.
Closes#19.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
subscriptions.pinned, per person, set by PATCH /api/feeds/{id} {pinned} and
returned as FeedRow.pinned. Kept out of Sub, which the scanner merges into its
policy; set_subscription names its columns, so saving a feed's settings leaves
the pin alone (tested).
Pinned feeds come first in the list, a pin before the name and a rule under the
block: a pinned folder with its feeds under it, a feed from inside one lifted out
of it. The pin button is on both the feed and the folder page.
Closes#33.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- users.theme and users.theme_mode, added by migrate(); GET /api/me returns them
and PATCH /api/me saves them, refusing anything but a plain name and
light/dark/auto, since index() writes them into the page's <html> tag.
- The page arrives with data-theme and data-choice already on <html> (and
data-mode unless Auto), so it is drawn in the account's theme from the start.
- A theme a browser kept in localStorage goes up to the account once, the first
time an account with none loads the page.
- Saves go one at a time, each with the choice as it stands: sent all at once, a
quick run through the list could land out of order and keep a theme passed on
the way. The browser test caught it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jeffgeerling.com answers 403 to an image request whose Referer is another
site, so his posts showed a broken image on iOS and the alt text on desktop.
The sanitiser now gives every <img> referrerpolicy="no-referrer".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The logo as favicon, squared up (it is 128x121), at /favicon.png and at
/favicon.ico outside the auth layer, where a browser asking on its own got a
401; an apple-touch-icon on white (#32).
- An item not yet downloaded had its download bar on a line of its own under the
file icon, lifting the icon above its row's; the bar now sits under it without
taking space (#31).
- A feed error is Font Awesome's exclamation, hung in the margin where a folder's
triangle is, in the same column; a folder holding a failing feed has its
triangle turn red.
Closes#31, #32.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page loaded its script inline. It now names /app.js?v=<hash> (login.js for
the sign-in page), the hash of the script's contents: the script is served
immutable for a year and the page no-cache, so a browser fetches the script
again only when a deploy changes it and so its name.
Also fixes a race in the mark-everything-read test: it waited on a badge that
was seldom 0 to begin with, so a mark-unread still in flight could land after
the read-all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- web/src/*.ts: the script that was inline in index.html and login.html, split along its
existing sections. Still one scope, concatenated in order, not modules.
- web/build.mjs strips the types, puts the script in the page and minifies it with swc;
build.rs runs it into OUT_DIR and web.rs include_str!s the result. 137 KB -> 106 KB.
- npx tsc -p . type-checks web/src, loosely; the handful of annotations it needed
change no behaviour.
- The Docker build installs node and swc (npm ci --omit=dev).
- Two list requests racing no longer let the older one win, and switching tabs clears
the selection it closes, which made a browser test flaky.
Closes#23, #24.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Opening an item stays read: a list refresh that crossed with the write no longer
puts the unread dot back (#16).
- On the Unread tab the item you were reading goes when you move to the next (#17).
- Feed errors mark the feed with a red ! instead of a toast per failure (#20).
- The theme is chosen in Settings only (#15).
- The server leaves the Log button out of a non-admin's page, so it no longer flashes (#29).
- Relative images and links in a post resolve against the post's link (#28).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A feed can be minutes out: ReThinking's gave 41:23 for a 43:48 file,
which read 0:08 left with 2:33 to play. The player's length is kept in
entry_state beside the position, per listener, where no scan can put
the feed's figure back, and preferred to the feed's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening an episode marks it read, so filtering on read hid every
episode anyone had started. The player now also reports the length it
measured, filling in one the feed left out. Fixes#14.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inter (#11): the pages are set in Inter's variable font, served from the
binary at /inter.woff2 as the icon is, with its OFL licence beside it in
web/. Classic keeps Lucida Grande, the 2004 app's face.
Double audio (#12): WordPress numbers each audio player on a page by
adding ?_=N to its file's URL, so a post that embeds the file it encloses
listed it twice, and it was downloaded twice. The parser keeps the first
of an item's enclosures that differ only by that number. At startup the
repeats already stored fold into the first; where only the repeat had
been downloaded its file moves to the first rather than being deleted.
Pin heading (#13): the rows' icon buttons kept the browser's side
padding, which pushed their 16px icon 3px right of centre, and the
heading's icon sat at the left of its column. Both are centred now, and
the heading row takes the pixel of border the rows have, so every
heading sits over its column.
Closes#11, closes#12, closes#13.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Almost no blog names a category the Directory can use, so a feed can
carry one of its own in config.toml, set by an admin in the feed's
settings and used when the feed names none. The feed's own iTunes
category still wins. The field offers the categories the Directory
already shows, so a blog about games joins Games rather than starting a
second chip. Setting it on a feed from an OPML promotes it to config, as
any other shared setting does.
Closes#10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keeping an item is pinning it now: a thumbtack where the flag was, and
Pin, Pinned and Unpin where Keep, Kept and Stop keeping were, on the
toolbar, the item's own buttons, the filter tab, the table column, the
retention hint and the warning before deleting a shared file. Pinned is
the solid thumbtack and not pinned the same shape outlined, as the flag
had its regular and solid pair. The API and database keep `flagged`.
The icon test compared glyphs by their path alone, which the two pins
share; it compares the whole glyph now.
Closes#9.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
davewiner's OPML left config.toml before retire_group existed, so its
derived rows were skipped by every scan but never cleared. At startup the
daemon now retires every group whose parent is gone from config. And
retire_group unmanages a feed that has its own config entry instead of
dropping it: eleven of davewiner's were promoted without being unmanaged,
and dropping them as derived would have deleted their entries. That also
covers removing an OPML or Patreon subscription from the page.
Closes#3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An Atom title of type="html", and an RSS title in CDATA, reach the parser
with their entities intact, so The Verge's "Meta’s" showed as typed:
55 stored titles across 17 feeds. Titles are decoded one entity at a time
with quick-xml's HTML5 table, leaving an & that starts none ("Q&A") alone
rather than failing the whole title.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apple puts every tabletop and gaming show under Leisure, so the top level
alone put most of this server's podcasts behind one chip. Games says what
they are; a show with no subcategory keeps its top-level one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Feeds take their channel's first <itunes:category> into a new feeds.category
column; the migration drops ETag and Last-Modified once so every feed re-reads
on its normal schedule and picks one up. /api/popular and /api/directory carry
category and podcast (any audio or video enclosure). Directory becomes a grid of
cover-art tiles under a chip rail: All, Podcasts, Blogs, and a podcast's
categories once Podcasts is picked. Popular and Add a feed keep their rows.
Closes#4, closes#5, closes#6, closes#7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
doghouse answers 200 with "Unable to establish a DB connection", and parse()
reported two parser errors about end of input that buried it. A body that does
not start with < now reports its first line, and explain_failure flags it as
the publisher's problem. Malformed XML keeps the parsers' errors.
Fixes#2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZbKERNSt4vQfyGV8rvkqp
- Remember the feed/place and tab across a reload or new visit; an
unknown or unsubscribed one lands on All Subscriptions instead of the
first feed alphabetically.
- Add an Auto theme that follows the system's light/dark setting, and
move Dark/Light/Classic/Auto into Settings as a dropdown alongside the
header button's toggle.
- Add Currently Listening below Popular: episodes started and not
finished, across every subscribed feed, one tap to resume. Reuses the
existing entries/filter machinery (Filter::InProgress) rather than a
new endpoint.
- Likely fix for the iOS bug where the topbar stopped responding to taps
until a hard refresh (100vh -> 100dvh); unverified on a real device.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
- Stop scanning an OPML/Patreon feed's derived rows once nobody subscribes
to it; retire them (drop or orphan) the way sync_group already does when
the list itself drops one. This is what let 922 defunct davewiner feeds
keep scanning hourly after the OPML left config.
- Repair feed XML with a bare `&`, and give a plain reason (moved web page
with its new address when linked, or nothing yet for an empty body)
instead of a raw parser error.
- Show a failing feed's plain-English reason and next step (Unsubscribe /
Use the new address) in the sidebar and on its own page, once it has
been down a day.
- Fix four small UI bugs: show-note links open in a new tab, video files
play as video, an opened item no longer disappears from the Unread tab,
and Subscribe/Unsubscribe get their own icons.
- Fix Settings disappearing for non-admin accounts: it was hiding the
whole modal instead of just the admin-only parts (Users, the editable
schedule/quota, Save), which are the only parts the server actually
refuses them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
status is a terminal event. Broadcast, the healthcheck's answer ended any
ipx fetch that was watching a scan, which stopped reading at the next probe
while the scan carried on. It could not happen while status waited behind
the scan; answering it at once made it happen every 30 seconds. Each
connection's writer now takes private replies beside the broadcast, and
the test checks another client hears nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
The worker runs one job at a time, and status was one of its jobs, so the
Docker healthcheck waited behind the startup scan (54 seconds of it after
the last deploy) and timed out at 5. Any scan or download longer than
three probes would have had a working daemon marked unhealthy. The socket
now answers status straight away; everything else still queues. A test
fills the queue and checks status comes back anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
Sign out cleared ipx's cookies and showed its password page, while
Cloudflare Access still vouched for the person: nothing was signed out,
and the page looked like the wrong login. /api/me now says, for someone
the proxy signed in, where to go instead ([web] sign_out_url, which is
/cdn-cgi/access/logout behind Access), and /login sends anyone the proxy
vouches for on to their feeds. The header check both use is one function.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
An account made by hand before the proxy was set up is called what it was
given ('rays'), while Cloudflare Access vouches for an email address. With
auto_create_users on, the first visit through the tunnel would make a
second, empty account. Renaming keeps the id, so feeds, read state and
admin rights go with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
users.created comes back, beside a new last_login, for whoever maintains
the server. A password sign-in, the token link and a request through the
proxy all count, recorded to the hour so the proxy's per-request vouching
is not a write each time. Settings -> Users and ipx user list show both.
The three user queries now share one row mapping.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
libsyn served Daily Meditation Podcast's content:encoded cut at the '>'
inside a Tailwind class pasted from a web app, so 57 items began halfway
through a tag and the page showed the rest of it as text. Their
description was whole. A body that closes an attribute list before any
tag opens now falls back to the description, for RSS and Atom alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
Drops the created columns on users, subscriptions and sessions, which were
written by every insert and read by nothing, and migrate()'s add list, whose
columns all predate 0.3.0. Removes Db::subscribed_feed_ids (no callers),
Db::subscriber_count (one caller wanting > 0) and Managed.orphaned (never
read). The old-database test now builds the tables with foreign keys on.
Popular now lists the feeds inside an OPML or a Patreon creator, never the
collection, as Directory does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
Popular still counts an OPML as one feed, since everyone subscribed to it
counts for every feed inside and they would bury the rest. The directory is
for finding a show, so it lists them one by one and never the OPML. A feed
inside an OPML that looks private is hidden with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
Works through TODO.md from the 2026-09-12 over-engineering audit. Drops the
entries.read/flagged/position columns (migrate() removes them from older
databases), migrate_opml_children, the legacy interval_mins key, the
contrib/ systemd units, test-only Db wrappers, a duplicate token generator,
redundant logbuf visitors, unused page state and CSS, and the infer, dirs
and tokio-stream dependencies. The icon is served once as /icon.png instead
of inlined four times, taking about 94 KB off the two pages.
The adoption's subscription half was not dead: it gives a fresh install's
first admin the config's feeds. It stays as adopt_catalogue, now tested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
Works through TODO.md from the 2026-09-11 review. Unread badges, dots and
download bars take the icon's amber; the playing item is marked by EQ bars
that move only while it plays. The feed list is usable from the keyboard,
focus rings show everywhere, and folders get a mosaic of their shows' art
with the triangle hung in the margin. Sentence-case labels, fewer bold
weights, tinted initials tiles, shorter header lines, "Kept" everywhere,
and the list gets the room the empty panes had. Reduced motion is honoured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
A Patreon token pasted into Add feed, or a creator link without
&show=, becomes a folder of that creator's shows, found through
Patreon's web API and kept in step like a subscribed OPML (sync_group,
split out of sync_opml). A creator already read as one feed is split
too: each show takes over the files and read state it held
(Db::adopt). A creator with one show stays a plain feed.
Filter verdicts are judged again every scan, so turning on Allow
explicit brings skipped items back. Add feed has an explicit box.
Feeds in a group follow your settings on the group, as its dialog
said. A new feed no longer takes the id of a removed one at a
different URL and shows its old items. See CHANGELOG.md [Unreleased]
and docs/history.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wi22VSVrkAvqNj61eqHsm9
- Every column heading sorts (kept, title, feed, file type, size,
published); a second click reverses it. The server sorts through a
fixed whitelist (order_sql), so it covers the whole list, not the
fifty loaded; the choice is remembered in the browser.
- Size is its own column and shows KB for small files instead of
"0 MB". The Item heading is Title.
- Popular/Directory/Add feed: Subscribed is a green circle-check.
- Tests: every sort column runs and orders both ways (db); the table
sorts by title both ways and remembers across a reload (browser).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
- All Subscriptions' header checks every feed and marks everything read
(POST /api/read-all, the same feeds the view lists); it asks first.
- Minus unsubscribes everywhere (the feed header's x read as "close"),
x only closes or cancels, plus adds/subscribes/imports, and a dialog's
confirm carries its action's icon. Remaining word buttons, the player
and the folder arrow are Font Awesome 7.3.1 icons.
- Toolbar grouped by what it acts on (add, unsubscribe, scan | play,
read, keep); read and keep show the selected item's state.
- The OPML subscription page uses the same header as a feed.
- Fixed: Escape ignored inside a dialog's text box (Add feed could not
be closed with it), white password box in the dark theme, stray dot
in an undated item's details.
- Tests: one action one icon across toolbar, page and all 8 dialogs;
All Subscriptions mark everything read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
- Security: feeds from Patreon, Supercast, Supporting Cast, Glow and
Memberful are never listed in Popular or the Directory. A Supercast
feed keeps its key in the URL's path, which the query check missed, so
it was being listed.
- Adding a feed queues a scan of it, and an OPML import that added feeds
scans what is due, so items show without pressing Scan.
- A file deleted to save space, or by hand, looks as if it was never
downloaded: no "reaped" chip, just the Download button. The retention
summary says "deleted".
- The feed header keeps its title and stats to one line each and wraps
its buttons; a single feed's table drops the Feed column.
- Tests: adding a feed shows its item without Scan; a deleted file shows
no "reaped"; paid-feed hosts and acast public ids in the unit test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
- A toolbar across the window with the original's groups: add and
unsubscribe, play, mark read and keep for the selected item, scan, a
search box for what is showing, and Settings and Log (admins only).
- Directory, Popular and All Subscriptions sit at the top of the feed
list and open in the main pane; the Popular and Directory buttons and
their dialogs are gone.
- All Subscriptions lists every item from every feed you subscribe to:
GET /api/entries, the per-feed query with its scope widened. The
enclosure lookup after it matches files to rows by feed and guid, since
a page can now span feeds.
- Items are a table (unread, kept, item, feed, file, published) with a
Files pane beside it, the text below, and a status bar with totals. On
a phone the files follow the text and the table is title and date.
- Tests: enclosures are checked in #files; the toolbar's read, keep and
play act on the selected item; All Subscriptions holds only your feeds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
- GET /api/popular returns the ten most subscribed feeds. The new GET
/api/directory returns every feed that may be listed, sorted by name,
from the same list: everyone counted, you included, never a URL, never
a private feed or a feed inside an OPML. POST /api/popular/{id} still
subscribes to anything on it.
- A Directory button sits beside Popular; both open the same list
screen. The sidebar toolbar wraps rather than squeezing four buttons.
- Tests: the directory is A to Z, Popular is its top ten, Paid Show is
in neither, and subscribing works from the directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
- A Popular button beside + Feed opens the popular list directly; the
Add feed dialog keeps it too.
- The list counts every subscriber, you included. Your own feeds stay on
it, marked Subscribed, and clicking one opens it. Private feeds and
feeds inside an OPML are still never listed, for anyone.
- GET /api/popular rows carry `subscribed`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
- Add feed lists what other accounts subscribe to, most subscribers
first, and subscribes you by id (GET /api/popular, POST
/api/popular/{id}). Rows never carry a URL. Feeds from an OPML and
anything that looks private (a login, credentials in the URL, a key
such as auth= or token=) are never listed, and the subscribe route
checks the id against the same list.
- CHANGELOG.md follows Keep a Changelog 1.1.0: 0.1.0 (2026-09-09, the
CLI), 0.2.0 (2026-09-10, the web UI), 0.3.0 (2026-09-11, accounts and
sharing). The long-form entries moved unchanged to docs/history.md.
- Cargo.toml is 0.3.0. CLAUDE.md says how to add an entry and cut a
release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
- The import screen has a file picker beside the paste box. The page
reads the file, checks it looks like OPML before sending, and clears
the picker when it is refused and after it is imported. The file is
sent as text and never written to disk on the server.
- The server parses the OPML before touching anything and answers 400
"that is not an OPML file" (was a 500). subscribe_opml takes a parsed
document, so ipx import also refuses a non-OPML file by name.
- Tests: Settings' Export OPML download and paste import; uploading an
RSS file (refused) and a real OPML; the server's 400; the admin's
export round-tripped into a second account; ipx import/export in a
scratch config.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
Import predated accounts: it only added URLs missing from config.toml
and subscribed nobody. Importing another account's export did nothing
("Imported 0 feed(s)"), and a genuinely new feed had no subscriber, so
it was never scanned. Web and CLI import now share subscribe_opml,
which subscribes the caller (the CLI: the first admin) to every feed in
the file and reports new vs already-subscribed.
Export wrote the whole catalogue to anyone signed in, including other
people's private feed URLs. It now lists only your own subscriptions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
- Settings > Manage users: add an account (password, or none for proxy
sign-in), toggle admin, remove. Backed by GET/POST /api/users and
PATCH/DELETE /api/users/{id}, 403 for non-admins. The only admin
cannot be demoted or removed.
- GET /api/logs is admin-only and the Log button is hidden for others;
the log names every account, feed and failed sign-in.
- Feeds inside an OPML list those with unread items first, in the
sidebar folder and on the subscription's page.
- Deploying is now buildx --push to 192.168.1.130:5000 and recreating
the ipodderx service of the Arcane project content; CLAUDE.md and the
README's Docker section say so.
- Tests: Playwright for user admin, the last-admin guard, 403s for a
non-admin and the unread ordering (new Aardvark Radio fixture); a unit
test for last_admin; the smoke test drives usersModal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
prune_entries still guarded on entries.flagged, which nothing writes
since read state moved to entry_state -- so starring a text item with no
file would not have saved it from the age sweep. It follows the reaper's
rule now, and takes orphaned read state with whatever it deletes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
reap_candidates still read entries.read/flagged, which nothing writes
since read state moved to entry_state -- so starring no longer protected
a file and the read-first ordering was dead. One file serves every
subscriber, so anyone starring it keeps it, and it counts as read only
once everyone subscribed has read it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
Read, starred and position move to entry_state; subscriptions carry each
person's keywords, auto-download, explicit and per-scan limit. The feed
list and unread counts are per person, and the existing library is
adopted by the admin on first start.
The feed URL, folder and schedule stay shared and admin-only: one file
serves everyone, so they describe the file rather than a preference.
Scanning merges subscribers' wants -- anyone wanting an item is enough --
via merge_policy, which is pure and tested.
Also: the test fixture wiped its data directory from every Playwright
worker, deleting the database out from under the running daemon.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
The per-feed schedule picker is gone and global Settings is admin-only,
enforced in the handlers with 403s rather than just hidden: polling costs
bandwidth and affects everyone reading the feed, so it belongs to the
operator. Folders, keywords and per-feed limits stay open to anyone.
docs/sso.md covers Cloudflare Zero Trust and Authentik end to end,
including why trusted_proxies names the proxy and not a subnet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
epAction's redraw closure called itself when handed a row, so Mark read
recursed until the stack blew; it now swaps that row in place. Opening an
item also marks it read, redrawn where it stands so nothing vanishes from
under the pointer on the Unread tab.
Step A of multi-user: users and sessions tables, Argon2id, a session
cookie, ipx user subcommands, and a trusted proxy header for Cloudflare
Zero Trust -- honoured only from a trusted_proxies address. The shared
token still works and is the admin. A new database starts with
admin/ipodderx.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
Marking the subscription read did nothing: its own row holds no entries.
read-all now resolves the feeds grouped under the id -- via
subscriptions(), so a child promoted to config is included -- and marks
those. Button sits before Unsubscribe, where every other feed keeps it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh