···11# nightshade
2233-Personal save-for-later and e-reader proxy. Runs next to a
44-[Miniflux](https://miniflux.app/) instance and adds:
33+Personal save-for-later and e-reader proxy, backed by [atproto](https://atproto.com/)
44+for data ownership and [Miniflux](https://miniflux.app/) for RSS polling.
5566-- save-arbitrary-URL: fetches the page, runs readability, stores the text in SQLite
77-- a plain-text HTTP API that paginates unread items for an e-ink device
88-- a Preact UI for subscribing to feeds, saving URLs, and managing both
66+- feed subscriptions and saved URLs live on your atproto PDS as records
77+ (`net.solanaceae.nightshade.feed`, `net.solanaceae.nightshade.save`)
88+- Nightshade syncs feeds two-way between atproto and Miniflux
99+- the e-reader endpoint fetches saved-URL bodies on demand (no local storage),
1010+ with a short in-memory TTL cache
1111+- Preact UI for atproto OAuth sign-in, feed management, and saving URLs
1212+1313+## Data shape
9141010-Miniflux holds the RSS subscriptions and entries. Nightshade stores only the
1111-one-off saves.
1515+atproto holds feed subscriptions and saved URLs (metadata only, no bodies).
1616+Miniflux mirrors the feed list and does the actual polling, entry storage, and
1717+entry read-state. Nightshade keeps nothing of its own apart from OAuth session
1818+files and a small sync snapshot. Kill the process and your data is fine.
12191320## Development
1421···1825pnpm dev # server on 8787, Vite on 5173
1926```
20272121-Vite proxies `/api` and `/device` through to the Node server.
2828+Leaving `NIGHTSHADE_PUBLIC_URL` unset runs atproto OAuth in loopback mode
2929+(`http://localhost` client_id) so you can sign in without a public URL.
22302331## Environment
24322525-| var | default | purpose |
2626-| --------------------- | ---------------------- | ------------------------------------------------------ |
2727-| `MINIFLUX_URL` | n/a | required, base URL of your Miniflux instance |
2828-| `MINIFLUX_TOKEN` | n/a | required (or `MINIFLUX_TOKEN_FILE`), Miniflux API key |
2929-| `MINIFLUX_TOKEN_FILE` | n/a | path to file containing the token (systemd credential) |
3030-| `NIGHTSHADE_PORT` | `8787` | HTTP port |
3131-| `NIGHTSHADE_DB` | `./nightshade.sqlite3` | SQLite file path |
3333+| var | default | purpose |
3434+| ----------------------- | -------- | ------------------------------------------------------------------ |
3535+| `MINIFLUX_URL` | n/a | required, base URL of your Miniflux instance |
3636+| `MINIFLUX_TOKEN` | n/a | required (or `MINIFLUX_TOKEN_FILE`), Miniflux API key |
3737+| `MINIFLUX_TOKEN_FILE` | n/a | path to file containing the token (systemd credential) |
3838+| `NIGHTSHADE_PUBLIC_URL` | n/a | unset for loopback OAuth; set to `https://host.tld` for production |
3939+| `NIGHTSHADE_PORT` | `8787` | HTTP port |
4040+| `NIGHTSHADE_DATA_DIR` | `./data` | directory for OAuth state/session files and sync snapshot |
32413342## HTTP API
34433535-### Browser / management (JSON)
4444+### Auth
36453737-- `GET /api/saves[?all=1]` — list saved items
3838-- `POST /api/saves` — save a URL (body `{url}`)
3939-- `DELETE /api/saves/:id` — remove a saved item
4040-- `POST /api/saves/:id/read` — mark read
4141-- `POST /api/saves/:id/unread` — mark unread
4242-- `GET /api/feeds` — list Miniflux subscriptions
4343-- `POST /api/feeds` — subscribe (body `{url, category_id?}`)
4444-- `DELETE /api/feeds/:id` — unsubscribe
4545-- `POST /api/feeds/refresh` — refresh all feeds
4646+- `GET /auth/status` — `{ authenticated, did? }`
4747+- `POST /auth/login` — body `{ handle }`, returns `{ url }` to redirect to
4848+- `GET /auth/callback` — OAuth redirect target
4949+- `POST /auth/logout` — revoke all local sessions
46504747-### E-reader device (plain text)
5151+### Management (JSON, requires session)
48524949-LAN only. No auth, no TLS.
5353+- `GET /api/saves[?all=1]` — list save records
5454+- `POST /api/saves` — body `{ url }`; creates atproto record, fetches title
5555+- `DELETE /api/saves/:rkey`
5656+- `POST /api/saves/:rkey/read` / `/unread`
5757+- `GET /api/feeds` — list feed records
5858+- `POST /api/feeds` — body `{ url, title? }`; creates atproto record, reconciles to Miniflux
5959+- `DELETE /api/feeds/:rkey` — deletes atproto record, reconciles to Miniflux
6060+- `POST /api/feeds/refresh` — refresh all Miniflux feeds now
6161+- `POST /api/sync` — trigger a reconciliation pass immediately
50625151-- `GET /device/list[?all=1&limit=N]` — merged list of unread items
5252-- `GET /device/item/:id[?page=N]` — one item, paginated (60-col wrap, 24 lines/page)
5353-- `POST /device/item/:id/read` — mark read
6363+### E-reader device (plain text, LAN-only, no auth/TLS)
54645555-IDs: Miniflux entries use the numeric entry id; saved items use `s<id>`.
6565+Save IDs are `s<rkey>`. RSS entry IDs are the numeric Miniflux entry id.
56665757-List format:
6767+- `GET /device/list[?all=1&limit=N]`
6868+- `GET /device/item/:id[?page=N]`
6969+- `POST /device/item/:id/read`
58705959-```
6060-COUNT:<n>
6161-===
6262-ID:<id>
6363-S:<rss|save>
6464-R:<0|1>
6565-D:<unix>
6666-T:<title>
6767-===
6868-...
6969-```
7171+## Sync behavior
70727171-Item format:
7373+Two-way sync between atproto and Miniflux, driven by a last-seen snapshot in
7474+`${NIGHTSHADE_DATA_DIR}/sync-state.json`. Runs on startup, after every
7575+feed-write through `/api/feeds`, and on a 5-minute timer.
72767373-```
7474-ID:<id>
7575-S:<rss|save>
7676-T:<title>
7777-U:<url>
7878-D:<unix>
7979-P:<cur>/<total>
8080----
8181-<wrapped body for this page>
8282-```
7777+- Feed added on atproto → subscribed in Miniflux
7878+- Feed added via Miniflux UI or OPML import → record created on atproto
7979+- Feed removed from either side → removed from the other
8080+- Saves don't sync (Miniflux has no saves concept)
+31
lexicons/net/solanaceae/nightshade/feed.json
···11+{
22+ "lexicon": 1,
33+ "id": "net.solanaceae.nightshade.feed",
44+ "description": "An RSS or Atom feed subscription.",
55+ "defs": {
66+ "main": {
77+ "type": "record",
88+ "key": "tid",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["url", "createdAt"],
1212+ "properties": {
1313+ "url": {
1414+ "type": "string",
1515+ "format": "uri",
1616+ "description": "Absolute URL of the feed (RSS/Atom XML endpoint)."
1717+ },
1818+ "title": {
1919+ "type": "string",
2020+ "maxLength": 500,
2121+ "description": "Human-readable title. May be set by the feed, the user, or the app."
2222+ },
2323+ "createdAt": {
2424+ "type": "string",
2525+ "format": "datetime"
2626+ }
2727+ }
2828+ }
2929+ }
3030+ }
3131+}
+36
lexicons/net/solanaceae/nightshade/save.json
···11+{
22+ "lexicon": 1,
33+ "id": "net.solanaceae.nightshade.save",
44+ "description": "A saved web page URL for later reading.",
55+ "defs": {
66+ "main": {
77+ "type": "record",
88+ "key": "tid",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["url", "createdAt"],
1212+ "properties": {
1313+ "url": {
1414+ "type": "string",
1515+ "format": "uri",
1616+ "description": "Absolute URL of the saved page."
1717+ },
1818+ "title": {
1919+ "type": "string",
2020+ "maxLength": 500,
2121+ "description": "Page title, usually extracted on first fetch."
2222+ },
2323+ "createdAt": {
2424+ "type": "string",
2525+ "format": "datetime"
2626+ },
2727+ "readAt": {
2828+ "type": "string",
2929+ "format": "datetime",
3030+ "description": "When the item was last marked read. Absence means unread."
3131+ }
3232+ }
3333+ }
3434+ }
3535+ }
3636+}
+20-5
module.nix
···5353 Nix store or the unit environment.
5454 '';
5555 };
5656+5757+ publicUrl = lib.mkOption {
5858+ type = lib.types.nullOr lib.types.str;
5959+ default = null;
6060+ example = "https://nightshade.solanaceae.net";
6161+ description = ''
6262+ Publicly reachable HTTPS URL of this Nightshade instance, used as the
6363+ base for atproto OAuth client metadata. Leave null for loopback/dev
6464+ OAuth mode (no public URL, only works for browsers on the same host).
6565+ '';
6666+ };
5667 };
57685869 config = lib.mkIf cfg.enable {
···7081 description = "Nightshade e-reader proxy";
7182 wantedBy = [ "multi-user.target" ];
7283 after = [ "network.target" ];
7373- environment = {
7474- NIGHTSHADE_PORT = toString cfg.port;
7575- NIGHTSHADE_DB = "${cfg.dataDir}/nightshade.sqlite3";
7676- MINIFLUX_URL = cfg.minifluxUrl;
7777- };
8484+ environment =
8585+ {
8686+ NIGHTSHADE_PORT = toString cfg.port;
8787+ NIGHTSHADE_DATA_DIR = cfg.dataDir;
8888+ MINIFLUX_URL = cfg.minifluxUrl;
8989+ }
9090+ // lib.optionalAttrs (cfg.publicUrl != null) {
9191+ NIGHTSHADE_PUBLIC_URL = cfg.publicUrl;
9292+ };
7893 serviceConfig = {
7994 ExecStart = lib.getExe cfg.package;
8095 User = cfg.user;